Compare commits
2 Commits
20dae0dce3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dfe3607b1 | |||
| ee885b2390 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -177,3 +177,4 @@ cython_debug/
|
|||||||
# Project data
|
# Project data
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
.aider*
|
||||||
|
|||||||
@@ -125,8 +125,20 @@ class Repository:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def delete_entity(self, entity_id: int) -> None:
|
def delete_entity(self, entity_id: int) -> None:
|
||||||
|
combo_ids = [
|
||||||
|
r["combination_id"]
|
||||||
|
for r in self.conn.execute(
|
||||||
|
"SELECT combination_id FROM combination_entities WHERE entity_id = ?",
|
||||||
|
(entity_id,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if combo_ids:
|
||||||
|
ph = ",".join("?" * len(combo_ids))
|
||||||
|
self.conn.execute(f"DELETE FROM combination_results WHERE combination_id IN ({ph})", combo_ids)
|
||||||
|
self.conn.execute(f"DELETE FROM combination_scores WHERE combination_id IN ({ph})", combo_ids)
|
||||||
|
self.conn.execute(f"DELETE FROM combination_entities WHERE combination_id IN ({ph})", combo_ids)
|
||||||
|
self.conn.execute(f"DELETE FROM combinations WHERE id IN ({ph})", combo_ids)
|
||||||
self.conn.execute("DELETE FROM dependencies WHERE entity_id = ?", (entity_id,))
|
self.conn.execute("DELETE FROM dependencies WHERE entity_id = ?", (entity_id,))
|
||||||
self.conn.execute("DELETE FROM combination_entities WHERE entity_id = ?", (entity_id,))
|
|
||||||
self.conn.execute("DELETE FROM entities WHERE id = ?", (entity_id,))
|
self.conn.execute("DELETE FROM entities WHERE id = ?", (entity_id,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
@@ -228,6 +240,99 @@ class Repository:
|
|||||||
rows = self.conn.execute("SELECT name FROM domains ORDER BY name").fetchall()
|
rows = self.conn.execute("SELECT name FROM domains ORDER BY name").fetchall()
|
||||||
return [self.get_domain(r["name"]) for r in rows]
|
return [self.get_domain(r["name"]) for r in rows]
|
||||||
|
|
||||||
|
def get_domain_by_id(self, domain_id: int) -> Domain | None:
|
||||||
|
row = self.conn.execute("SELECT * FROM domains WHERE id = ?", (domain_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
weights = self.conn.execute(
|
||||||
|
"""SELECT m.name, m.unit, dmw.weight, dmw.norm_min, dmw.norm_max, dmw.metric_id
|
||||||
|
FROM domain_metric_weights dmw
|
||||||
|
JOIN metrics m ON dmw.metric_id = m.id
|
||||||
|
WHERE dmw.domain_id = ?""",
|
||||||
|
(row["id"],),
|
||||||
|
).fetchall()
|
||||||
|
return Domain(
|
||||||
|
id=row["id"],
|
||||||
|
name=row["name"],
|
||||||
|
description=row["description"] or "",
|
||||||
|
metric_bounds=[
|
||||||
|
MetricBound(
|
||||||
|
metric_name=w["name"], weight=w["weight"],
|
||||||
|
norm_min=w["norm_min"], norm_max=w["norm_max"],
|
||||||
|
metric_id=w["metric_id"], unit=w["unit"] or "",
|
||||||
|
)
|
||||||
|
for w in weights
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_domain(self, domain_id: int, name: str, description: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE domains SET name = ?, description = ? WHERE id = ?",
|
||||||
|
(name, description, domain_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_metric_bound(self, domain_id: int, mb: MetricBound) -> MetricBound:
|
||||||
|
metric_id = self.ensure_metric(mb.metric_name, mb.unit)
|
||||||
|
mb.metric_id = metric_id
|
||||||
|
self.conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO domain_metric_weights
|
||||||
|
(domain_id, metric_id, weight, norm_min, norm_max)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(domain_id, metric_id, mb.weight, mb.norm_min, mb.norm_max),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return mb
|
||||||
|
|
||||||
|
def update_metric_bound(
|
||||||
|
self, domain_id: int, metric_id: int, weight: float, norm_min: float, norm_max: float, unit: str
|
||||||
|
) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""UPDATE domain_metric_weights
|
||||||
|
SET weight = ?, norm_min = ?, norm_max = ?
|
||||||
|
WHERE domain_id = ? AND metric_id = ?""",
|
||||||
|
(weight, norm_min, norm_max, domain_id, metric_id),
|
||||||
|
)
|
||||||
|
if unit:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE metrics SET unit = ? WHERE id = ?",
|
||||||
|
(unit, metric_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_metric_bound(self, domain_id: int, metric_id: int) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM domain_metric_weights WHERE domain_id = ? AND metric_id = ?",
|
||||||
|
(domain_id, metric_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_domain(self, domain_id: int) -> None:
|
||||||
|
self.conn.execute("DELETE FROM pipeline_runs WHERE domain_id = ?", (domain_id,))
|
||||||
|
self.conn.execute("DELETE FROM combination_results WHERE domain_id = ?", (domain_id,))
|
||||||
|
self.conn.execute("DELETE FROM combination_scores WHERE domain_id = ?", (domain_id,))
|
||||||
|
self.conn.execute("DELETE FROM domain_metric_weights WHERE domain_id = ?", (domain_id,))
|
||||||
|
self.conn.execute("DELETE FROM domains WHERE id = ?", (domain_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def reset_domain_results(self, domain_name: str) -> int:
|
||||||
|
"""Delete all pipeline results for a domain so it can be re-run from scratch.
|
||||||
|
|
||||||
|
Returns the number of result rows deleted.
|
||||||
|
"""
|
||||||
|
domain = self.get_domain(domain_name)
|
||||||
|
if not domain:
|
||||||
|
return 0
|
||||||
|
count = self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM combination_results WHERE domain_id = ?",
|
||||||
|
(domain.id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
self.conn.execute("DELETE FROM combination_scores WHERE domain_id = ?", (domain.id,))
|
||||||
|
self.conn.execute("DELETE FROM combination_results WHERE domain_id = ?", (domain.id,))
|
||||||
|
self.conn.execute("DELETE FROM pipeline_runs WHERE domain_id = ?", (domain.id,))
|
||||||
|
self.conn.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
# ── Combinations ────────────────────────────────────────────
|
# ── Combinations ────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -265,12 +370,16 @@ class Repository:
|
|||||||
def update_combination_status(
|
def update_combination_status(
|
||||||
self, combo_id: int, status: str, block_reason: str | None = None
|
self, combo_id: int, status: str, block_reason: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
# Don't downgrade 'reviewed' to 'scored' — preserve human review state
|
# Don't downgrade from higher pass states — preserves human/LLM review data
|
||||||
if status == "scored":
|
if status in ("scored", "llm_reviewed"):
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(
|
||||||
"SELECT status FROM combinations WHERE id = ?", (combo_id,)
|
"SELECT status FROM combinations WHERE id = ?", (combo_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row and row["status"] == "reviewed":
|
if row:
|
||||||
|
cur = row["status"]
|
||||||
|
if status == "scored" and cur in ("llm_reviewed", "reviewed"):
|
||||||
|
return
|
||||||
|
if status == "llm_reviewed" and cur == "reviewed":
|
||||||
return
|
return
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE combinations SET status = ?, block_reason = ? WHERE id = ?",
|
"UPDATE combinations SET status = ?, block_reason = ? WHERE id = ?",
|
||||||
@@ -292,6 +401,60 @@ class Repository:
|
|||||||
block_reason=row["block_reason"], entities=entities,
|
block_reason=row["block_reason"], entities=entities,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _bulk_load_combinations(self, combo_ids: list[int]) -> dict[int, Combination]:
|
||||||
|
"""Load multiple Combinations in O(4) queries instead of O(N*M)."""
|
||||||
|
if not combo_ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join("?" * len(combo_ids))
|
||||||
|
combo_rows = self.conn.execute(
|
||||||
|
f"SELECT * FROM combinations WHERE id IN ({ph})", combo_ids
|
||||||
|
).fetchall()
|
||||||
|
combos: dict[int, Combination] = {
|
||||||
|
r["id"]: Combination(
|
||||||
|
id=r["id"], hash=r["hash"], status=r["status"],
|
||||||
|
block_reason=r["block_reason"], entities=[],
|
||||||
|
)
|
||||||
|
for r in combo_rows
|
||||||
|
}
|
||||||
|
ce_rows = self.conn.execute(
|
||||||
|
f"SELECT combination_id, entity_id FROM combination_entities WHERE combination_id IN ({ph})",
|
||||||
|
combo_ids,
|
||||||
|
).fetchall()
|
||||||
|
combo_to_eids: dict[int, list[int]] = {}
|
||||||
|
for r in ce_rows:
|
||||||
|
combo_to_eids.setdefault(r["combination_id"], []).append(r["entity_id"])
|
||||||
|
|
||||||
|
entity_ids = list({r["entity_id"] for r in ce_rows})
|
||||||
|
if entity_ids:
|
||||||
|
eph = ",".join("?" * len(entity_ids))
|
||||||
|
entity_rows = self.conn.execute(
|
||||||
|
f"""SELECT e.id, e.name, e.description, d.name as dimension, e.dimension_id
|
||||||
|
FROM entities e JOIN dimensions d ON e.dimension_id = d.id
|
||||||
|
WHERE e.id IN ({eph})""",
|
||||||
|
entity_ids,
|
||||||
|
).fetchall()
|
||||||
|
dep_rows = self.conn.execute(
|
||||||
|
f"SELECT * FROM dependencies WHERE entity_id IN ({eph})", entity_ids
|
||||||
|
).fetchall()
|
||||||
|
deps_by_entity: dict[int, list[Dependency]] = {}
|
||||||
|
for r in dep_rows:
|
||||||
|
deps_by_entity.setdefault(r["entity_id"], []).append(Dependency(
|
||||||
|
id=r["id"], category=r["category"], key=r["key"],
|
||||||
|
value=r["value"], unit=r["unit"], constraint_type=r["constraint_type"],
|
||||||
|
))
|
||||||
|
entities_by_id: dict[int, Entity] = {
|
||||||
|
r["id"]: Entity(
|
||||||
|
id=r["id"], name=r["name"], description=r["description"] or "",
|
||||||
|
dimension=r["dimension"], dimension_id=r["dimension_id"],
|
||||||
|
dependencies=deps_by_entity.get(r["id"], []),
|
||||||
|
)
|
||||||
|
for r in entity_rows
|
||||||
|
}
|
||||||
|
for cid, eids in combo_to_eids.items():
|
||||||
|
if cid in combos:
|
||||||
|
combos[cid].entities = [entities_by_id[eid] for eid in eids if eid in entities_by_id]
|
||||||
|
return combos
|
||||||
|
|
||||||
def list_combinations(self, status: str | None = None) -> list[Combination]:
|
def list_combinations(self, status: str | None = None) -> list[Combination]:
|
||||||
if status:
|
if status:
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
@@ -299,7 +462,9 @@ class Repository:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = self.conn.execute("SELECT id FROM combinations ORDER BY id").fetchall()
|
rows = self.conn.execute("SELECT id FROM combinations ORDER BY id").fetchall()
|
||||||
return [self.get_combination(r["id"]) for r in rows]
|
ids = [r["id"] for r in rows]
|
||||||
|
combos = self._bulk_load_combinations(ids)
|
||||||
|
return [combos[i] for i in ids if i in combos]
|
||||||
|
|
||||||
# ── Scores & Results ────────────────────────────────────────
|
# ── Scores & Results ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -385,9 +550,13 @@ class Repository:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if not row or row["total"] == 0:
|
if not row or row["total"] == 0:
|
||||||
return None
|
return None
|
||||||
# Also count blocked combos (they have no results but exist)
|
|
||||||
blocked = self.conn.execute(
|
blocked = self.conn.execute(
|
||||||
"SELECT COUNT(*) as cnt FROM combinations WHERE status = 'blocked'"
|
"""SELECT COUNT(*) as cnt
|
||||||
|
FROM combinations c
|
||||||
|
JOIN combination_results cr ON cr.combination_id = c.id
|
||||||
|
JOIN domains d ON cr.domain_id = d.id
|
||||||
|
WHERE c.status = 'blocked' AND d.name = ?""",
|
||||||
|
(domain_name,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return {
|
return {
|
||||||
"total_results": row["total"],
|
"total_results": row["total"],
|
||||||
@@ -422,19 +591,20 @@ class Repository:
|
|||||||
params.append(status)
|
params.append(status)
|
||||||
query += " ORDER BY cr.composite_score DESC"
|
query += " ORDER BY cr.composite_score DESC"
|
||||||
rows = self.conn.execute(query, params).fetchall()
|
rows = self.conn.execute(query, params).fetchall()
|
||||||
results = []
|
combo_ids = [r["combination_id"] for r in rows]
|
||||||
for r in rows:
|
combos = self._bulk_load_combinations(combo_ids)
|
||||||
combo = self.get_combination(r["combination_id"])
|
return [
|
||||||
results.append({
|
{
|
||||||
"combination": combo,
|
"combination": combos.get(r["combination_id"]),
|
||||||
"composite_score": r["composite_score"],
|
"composite_score": r["composite_score"],
|
||||||
"novelty_flag": r["novelty_flag"],
|
"novelty_flag": r["novelty_flag"],
|
||||||
"llm_review": r["llm_review"],
|
"llm_review": r["llm_review"],
|
||||||
"human_notes": r["human_notes"],
|
"human_notes": r["human_notes"],
|
||||||
"pass_reached": r["pass_reached"],
|
"pass_reached": r["pass_reached"],
|
||||||
"domain_id": r["domain_id"],
|
"domain_id": r["domain_id"],
|
||||||
})
|
}
|
||||||
return results
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
def get_top_results(self, domain_name: str, limit: int = 10) -> list[dict]:
|
def get_top_results(self, domain_name: str, limit: int = 10) -> list[dict]:
|
||||||
"""Return top-N results for a domain, ordered by composite_score DESC."""
|
"""Return top-N results for a domain, ordered by composite_score DESC."""
|
||||||
@@ -448,18 +618,30 @@ class Repository:
|
|||||||
LIMIT ?""",
|
LIMIT ?""",
|
||||||
(domain_name, limit),
|
(domain_name, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
results = []
|
combo_ids = [r["combination_id"] for r in rows]
|
||||||
for r in rows:
|
combos = self._bulk_load_combinations(combo_ids)
|
||||||
combo = self.get_combination(r["combination_id"])
|
return [
|
||||||
results.append({
|
{
|
||||||
"combination": combo,
|
"combination": combos.get(r["combination_id"]),
|
||||||
"composite_score": r["composite_score"],
|
"composite_score": r["composite_score"],
|
||||||
"novelty_flag": r["novelty_flag"],
|
"novelty_flag": r["novelty_flag"],
|
||||||
"llm_review": r["llm_review"],
|
"llm_review": r["llm_review"],
|
||||||
"human_notes": r["human_notes"],
|
"human_notes": r["human_notes"],
|
||||||
"pass_reached": r["pass_reached"],
|
"pass_reached": r["pass_reached"],
|
||||||
})
|
}
|
||||||
return results
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_results_for_combination(self, combo_id: int) -> list[dict]:
|
||||||
|
"""Return all domain results for a combination."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""SELECT cr.*, d.name as domain_name
|
||||||
|
FROM combination_results cr
|
||||||
|
JOIN domains d ON cr.domain_id = d.id
|
||||||
|
WHERE cr.combination_id = ?""",
|
||||||
|
(combo_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
# ── Pipeline Runs ────────────────────────────────────────
|
# ── Pipeline Runs ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -473,10 +655,19 @@ class Repository:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
|
_PIPELINE_RUN_UPDATABLE = frozenset({
|
||||||
|
"status", "total_combos", "combos_pass1", "combos_pass2",
|
||||||
|
"combos_pass3", "combos_pass4", "current_pass",
|
||||||
|
"error_message", "started_at", "completed_at",
|
||||||
|
})
|
||||||
|
|
||||||
def update_pipeline_run(self, run_id: int, **fields) -> None:
|
def update_pipeline_run(self, run_id: int, **fields) -> None:
|
||||||
"""Update arbitrary fields on a pipeline_run."""
|
"""Update fields on a pipeline_run. Only allowlisted column names are accepted."""
|
||||||
if not fields:
|
if not fields:
|
||||||
return
|
return
|
||||||
|
invalid = set(fields) - self._PIPELINE_RUN_UPDATABLE
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(f"Invalid pipeline_run fields: {invalid}")
|
||||||
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
||||||
values = list(fields.values())
|
values = list(fields.values())
|
||||||
values.append(run_id)
|
values.append(run_id)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ConstraintResolver:
|
|||||||
self._check_mutual_exclusion(all_deps, result)
|
self._check_mutual_exclusion(all_deps, result)
|
||||||
self._check_range_incompatibility(all_deps, result)
|
self._check_range_incompatibility(all_deps, result)
|
||||||
self._check_force_scale(combination, result)
|
self._check_force_scale(combination, result)
|
||||||
|
self._check_energy_density(combination, result)
|
||||||
self._check_unmet_requirements(all_deps, result)
|
self._check_unmet_requirements(all_deps, result)
|
||||||
|
|
||||||
if result.violations:
|
if result.violations:
|
||||||
@@ -155,6 +156,40 @@ class ConstraintResolver:
|
|||||||
f"(under-powered)"
|
f"(under-powered)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_energy_density(
|
||||||
|
self, combination: Combination, result: ConstraintResult
|
||||||
|
) -> None:
|
||||||
|
"""Rule 6: If power source energy density << platform minimum → warn/block.
|
||||||
|
|
||||||
|
Uses a 25% threshold: below 25% of required → hard block (> 4x deficit).
|
||||||
|
"""
|
||||||
|
density_provided: list[tuple[str, float]] = []
|
||||||
|
density_required: list[tuple[str, float]] = []
|
||||||
|
|
||||||
|
for entity in combination.entities:
|
||||||
|
for dep in entity.dependencies:
|
||||||
|
if dep.key == "energy_density_wh_kg" and dep.constraint_type == "provides":
|
||||||
|
density_provided.append((entity.name, float(dep.value)))
|
||||||
|
elif dep.key == "energy_density_wh_kg" and dep.constraint_type == "range_min":
|
||||||
|
density_required.append((entity.name, float(dep.value)))
|
||||||
|
|
||||||
|
for req_name, req_density in density_required:
|
||||||
|
if not density_provided:
|
||||||
|
continue # No stored energy source in this combo — skip check
|
||||||
|
for prov_name, prov_density in density_provided:
|
||||||
|
if prov_density < req_density * 0.25:
|
||||||
|
result.violations.append(
|
||||||
|
f"{prov_name} provides {prov_density:.0f} Wh/kg but "
|
||||||
|
f"{req_name} requires {req_density:.0f} Wh/kg "
|
||||||
|
f"(energy density deficit > 4x)"
|
||||||
|
)
|
||||||
|
elif prov_density < req_density:
|
||||||
|
result.warnings.append(
|
||||||
|
f"{prov_name} provides {prov_density:.0f} Wh/kg but "
|
||||||
|
f"{req_name} requires {req_density:.0f} Wh/kg "
|
||||||
|
f"(under-density)"
|
||||||
|
)
|
||||||
|
|
||||||
def _check_unmet_requirements(
|
def _check_unmet_requirements(
|
||||||
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
self, all_deps: list[tuple[str, Dependency]], result: ConstraintResult
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ from physcom.db.repository import Repository
|
|||||||
from physcom.engine.combinator import generate_combinations
|
from physcom.engine.combinator import generate_combinations
|
||||||
from physcom.engine.constraint_resolver import ConstraintResolver, ConstraintResult
|
from physcom.engine.constraint_resolver import ConstraintResolver, ConstraintResult
|
||||||
from physcom.engine.scorer import Scorer
|
from physcom.engine.scorer import Scorer
|
||||||
from physcom.llm.base import LLMProvider
|
from physcom.llm.base import LLMProvider, LLMRateLimitError
|
||||||
from physcom.models.combination import Combination, ScoredResult
|
from physcom.models.combination import Combination, ScoredResult
|
||||||
from physcom.models.domain import Domain
|
from physcom.models.domain import Domain
|
||||||
|
|
||||||
@@ -184,12 +185,9 @@ class Pipeline:
|
|||||||
if 2 in passes and existing_pass < 2:
|
if 2 in passes and existing_pass < 2:
|
||||||
description = _describe_combination(combo)
|
description = _describe_combination(combo)
|
||||||
if self.llm:
|
if self.llm:
|
||||||
try:
|
|
||||||
raw_metrics = self.llm.estimate_physics(
|
raw_metrics = self.llm.estimate_physics(
|
||||||
description, metric_names
|
description, metric_names
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
raw_metrics = self._stub_estimate(combo, metric_names)
|
|
||||||
else:
|
else:
|
||||||
raw_metrics = self._stub_estimate(combo, metric_names)
|
raw_metrics = self._stub_estimate(combo, metric_names)
|
||||||
|
|
||||||
@@ -287,7 +285,6 @@ class Pipeline:
|
|||||||
and cur_result["composite_score"] is not None
|
and cur_result["composite_score"] is not None
|
||||||
and cur_result["composite_score"] >= score_threshold
|
and cur_result["composite_score"] >= score_threshold
|
||||||
):
|
):
|
||||||
try:
|
|
||||||
description = _describe_combination(combo)
|
description = _describe_combination(combo)
|
||||||
db_scores = self.repo.get_combination_scores(
|
db_scores = self.repo.get_combination_scores(
|
||||||
combo.id, domain.id
|
combo.id, domain.id
|
||||||
@@ -297,9 +294,20 @@ class Pipeline:
|
|||||||
for s in db_scores
|
for s in db_scores
|
||||||
if s["normalized_score"] is not None
|
if s["normalized_score"] is not None
|
||||||
}
|
}
|
||||||
|
review: str | None = None
|
||||||
|
try:
|
||||||
review = self.llm.review_plausibility(
|
review = self.llm.review_plausibility(
|
||||||
description, score_dict
|
description, score_dict
|
||||||
)
|
)
|
||||||
|
except LLMRateLimitError as exc:
|
||||||
|
self._wait_for_rate_limit(run_id, exc.retry_after)
|
||||||
|
try:
|
||||||
|
review = self.llm.review_plausibility(
|
||||||
|
description, score_dict
|
||||||
|
)
|
||||||
|
except LLMRateLimitError:
|
||||||
|
pass # still limited; skip, retry next run
|
||||||
|
if review is not None:
|
||||||
self.repo.save_result(
|
self.repo.save_result(
|
||||||
combo.id,
|
combo.id,
|
||||||
domain.id,
|
domain.id,
|
||||||
@@ -309,12 +317,13 @@ class Pipeline:
|
|||||||
llm_review=review,
|
llm_review=review,
|
||||||
human_notes=cur_result.get("human_notes"),
|
human_notes=cur_result.get("human_notes"),
|
||||||
)
|
)
|
||||||
|
self.repo.update_combination_status(
|
||||||
|
combo.id, "llm_reviewed"
|
||||||
|
)
|
||||||
result.pass4_reviewed += 1
|
result.pass4_reviewed += 1
|
||||||
self._update_run_counters(
|
self._update_run_counters(
|
||||||
run_id, result, current_pass=4
|
run_id, result, current_pass=4
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
pass # skip this combo; don't abort the run
|
|
||||||
|
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
if run_id is not None:
|
if run_id is not None:
|
||||||
@@ -337,6 +346,18 @@ class Pipeline:
|
|||||||
result.top_results = self.repo.get_top_results(domain.name, limit=20)
|
result.top_results = self.repo.get_top_results(domain.name, limit=20)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _wait_for_rate_limit(self, run_id: int | None, retry_after: int) -> None:
|
||||||
|
"""Mark run rate_limited, sleep with cancel checks, then resume."""
|
||||||
|
if run_id is not None:
|
||||||
|
self.repo.update_pipeline_run(run_id, status="rate_limited")
|
||||||
|
waited = 0
|
||||||
|
while waited < retry_after:
|
||||||
|
time.sleep(5)
|
||||||
|
waited += 5
|
||||||
|
self._check_cancelled(run_id)
|
||||||
|
if run_id is not None:
|
||||||
|
self.repo.update_pipeline_run(run_id, status="running")
|
||||||
|
|
||||||
def _stub_estimate(
|
def _stub_estimate(
|
||||||
self, combo: Combination, metric_names: list[str]
|
self, combo: Combination, metric_names: list[str]
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ from __future__ import annotations
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class LLMRateLimitError(Exception):
|
||||||
|
"""Raised by a provider when the API rate limit is exceeded.
|
||||||
|
|
||||||
|
The pipeline catches this, waits retry_after seconds (checking for
|
||||||
|
cancellation), then retries. retry_after defaults to 65s if unknown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, retry_after: int = 65) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
class LLMProvider(ABC):
|
class LLMProvider(ABC):
|
||||||
"""Abstract LLM interface for physics estimation and plausibility review."""
|
"""Abstract LLM interface for physics estimation and plausibility review."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import math
|
||||||
|
|
||||||
from physcom.llm.base import LLMProvider
|
from physcom.llm.base import LLMProvider, LLMRateLimitError
|
||||||
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
|
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
|
||||||
|
|
||||||
|
|
||||||
@@ -29,9 +30,14 @@ class GeminiLLMProvider(LLMProvider):
|
|||||||
description=combination_description,
|
description=combination_description,
|
||||||
metrics=", ".join(metrics),
|
metrics=", ".join(metrics),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
response = self._client.models.generate_content(
|
response = self._client.models.generate_content(
|
||||||
model=self._model, contents=prompt
|
model=self._model, contents=prompt
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if "429" in str(exc) or "RESOURCE_EXHAUSTED" in str(exc):
|
||||||
|
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
|
||||||
|
raise
|
||||||
return self._parse_json(response.text, metrics)
|
return self._parse_json(response.text, metrics)
|
||||||
|
|
||||||
def review_plausibility(
|
def review_plausibility(
|
||||||
@@ -42,11 +48,21 @@ class GeminiLLMProvider(LLMProvider):
|
|||||||
description=combination_description,
|
description=combination_description,
|
||||||
scores=scores_str,
|
scores=scores_str,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
response = self._client.models.generate_content(
|
response = self._client.models.generate_content(
|
||||||
model=self._model, contents=prompt
|
model=self._model, contents=prompt
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if "429" in str(exc) or "RESOURCE_EXHAUSTED" in str(exc):
|
||||||
|
raise LLMRateLimitError(str(exc), self._parse_retry_after(exc)) from exc
|
||||||
|
raise
|
||||||
return response.text.strip()
|
return response.text.strip()
|
||||||
|
|
||||||
|
def _parse_retry_after(self, exc: Exception) -> int:
|
||||||
|
"""Extract retry delay from the error message, with a safe default."""
|
||||||
|
m = re.search(r"retry in (\d+(?:\.\d+)?)", str(exc))
|
||||||
|
return math.ceil(float(m.group(1))) + 5 if m else 65
|
||||||
|
|
||||||
def _parse_json(self, text: str, metrics: list[str]) -> dict[str, float]:
|
def _parse_json(self, text: str, metrics: list[str]) -> dict[str, float]:
|
||||||
"""Strip markdown fences and parse JSON; fall back to 0.5 per metric on error."""
|
"""Strip markdown fences and parse JSON; fall back to 0.5 per metric on error."""
|
||||||
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
|
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ PLATFORMS: list[Entity] = [
|
|||||||
Dependency("force", "force_required_watts", "100000", "watts", "range_min"),
|
Dependency("force", "force_required_watts", "100000", "watts", "range_min"),
|
||||||
Dependency("infrastructure", "runway", "true", None, "requires"),
|
Dependency("infrastructure", "runway", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "air", None, "requires"),
|
Dependency("environment", "medium", "air", None, "requires"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "400", "Wh/kg", "range_min"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -111,6 +112,7 @@ PLATFORMS: list[Entity] = [
|
|||||||
Dependency("force", "force_required_watts", "1000000", "watts", "range_min"),
|
Dependency("force", "force_required_watts", "1000000", "watts", "range_min"),
|
||||||
Dependency("infrastructure", "launch_facility", "true", None, "requires"),
|
Dependency("infrastructure", "launch_facility", "true", None, "requires"),
|
||||||
Dependency("environment", "medium", "space", None, "requires"),
|
Dependency("environment", "medium", "space", None, "requires"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "2000", "Wh/kg", "range_min"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -139,6 +141,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
||||||
Dependency("physical", "mass_kg", "50", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "50", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "1500", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -150,6 +153,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("infrastructure", "fuel_infrastructure", "charging_station", None, "requires"),
|
Dependency("infrastructure", "fuel_infrastructure", "charging_station", None, "requires"),
|
||||||
Dependency("physical", "mass_kg", "10", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "10", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "moderate_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "moderate_continuous", None, "provides"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -161,6 +165,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("infrastructure", "fuel_infrastructure", "hydrogen_station", None, "requires"),
|
Dependency("infrastructure", "fuel_infrastructure", "hydrogen_station", None, "requires"),
|
||||||
Dependency("physical", "mass_kg", "30", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "30", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "600", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -172,6 +177,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("infrastructure", "fuel_infrastructure", "none", None, "requires"),
|
Dependency("infrastructure", "fuel_infrastructure", "none", None, "requires"),
|
||||||
Dependency("physical", "mass_kg", "0", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "0", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -184,6 +190,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("physical", "mass_kg", "2000", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "2000", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
|
||||||
Dependency("material", "radiation_shielding", "true", None, "requires"),
|
Dependency("material", "radiation_shielding", "true", None, "requires"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "500000", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -196,6 +203,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
Dependency("environment", "atmosphere", "standard", None, "requires"),
|
||||||
Dependency("physical", "mass_kg", "500", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "500", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "high_continuous", None, "provides"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "400", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -220,6 +228,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("infrastructure", "fuel_infrastructure", "ammunition", None, "requires"),
|
Dependency("infrastructure", "fuel_infrastructure", "ammunition", None, "requires"),
|
||||||
Dependency("physical", "mass_kg", "100", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "100", "kg", "range_min"),
|
||||||
Dependency("force", "thrust_profile", "high_burst", None, "provides"),
|
Dependency("force", "thrust_profile", "high_burst", None, "provides"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Entity(
|
Entity(
|
||||||
@@ -232,6 +241,7 @@ POWER_SOURCES: list[Entity] = [
|
|||||||
Dependency("physical", "mass_kg", "0", "kg", "range_min"),
|
Dependency("physical", "mass_kg", "0", "kg", "range_min"),
|
||||||
Dependency("environment", "ground_surface", "true", None, "requires"),
|
Dependency("environment", "ground_surface", "true", None, "requires"),
|
||||||
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
|
Dependency("force", "thrust_profile", "low_continuous", None, "provides"),
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, g
|
from flask import Flask, g
|
||||||
@@ -12,6 +13,29 @@ from physcom.db.repository import Repository
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_DB = Path("data/physcom.db")
|
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:
|
def get_repo() -> Repository:
|
||||||
@@ -31,7 +55,7 @@ def close_db(exc: BaseException | None = None) -> None:
|
|||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
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)
|
app.teardown_appcontext(close_db)
|
||||||
|
|
||||||
@@ -57,4 +81,4 @@ def create_app() -> Flask:
|
|||||||
def run() -> None:
|
def run() -> None:
|
||||||
"""Entry point for `physcom-web` script."""
|
"""Entry point for `physcom-web` script."""
|
||||||
app = create_app()
|
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 __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
|
from physcom_web.app import get_repo
|
||||||
|
|
||||||
bp = Blueprint("domains", __name__, url_prefix="/domains")
|
bp = Blueprint("domains", __name__, url_prefix="/domains")
|
||||||
@@ -14,3 +15,100 @@ def domain_list():
|
|||||||
repo = get_repo()
|
repo = get_repo()
|
||||||
domains = repo.list_domains()
|
domains = repo.list_domains()
|
||||||
return render_template("domains/list.html", domains=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."""
|
"""Set a running pipeline to cancelled. The pipeline checks this flag."""
|
||||||
repo = get_repo()
|
repo = get_repo()
|
||||||
run = repo.get_pipeline_run(run_id)
|
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")
|
repo.update_pipeline_run(run_id, status="cancelled")
|
||||||
flash(f"Run #{run_id} cancellation requested.", "info")
|
flash(f"Run #{run_id} cancellation requested.", "info")
|
||||||
return redirect(url_for("pipeline.pipeline_form"))
|
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"])
|
@bp.route("/<domain_name>/<int:combo_id>/review", methods=["POST"])
|
||||||
def submit_review(domain_name: str, combo_id: int):
|
def submit_review(domain_name: str, combo_id: int):
|
||||||
repo = get_repo()
|
repo = get_repo()
|
||||||
@@ -84,6 +96,7 @@ def submit_review(domain_name: str, combo_id: int):
|
|||||||
combo_id, domain.id, composite_score,
|
combo_id, domain.id, composite_score,
|
||||||
pass_reached=5,
|
pass_reached=5,
|
||||||
novelty_flag=novelty_flag,
|
novelty_flag=novelty_flag,
|
||||||
|
llm_review=existing.get("llm_review") if existing else None,
|
||||||
human_notes=human_notes,
|
human_notes=human_notes,
|
||||||
)
|
)
|
||||||
repo.update_combination_status(combo_id, "reviewed")
|
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-valid { background: #dcfce7; color: #166534; }
|
||||||
.badge-blocked { background: #fee2e2; color: #991b1b; }
|
.badge-blocked { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-scored { background: #dbeafe; color: #1e40af; }
|
.badge-scored { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-llm_reviewed { background: #e0f2fe; color: #0369a1; }
|
||||||
.badge-reviewed { background: #f3e8ff; color: #6b21a8; }
|
.badge-reviewed { background: #f3e8ff; color: #6b21a8; }
|
||||||
.badge-pending { background: #fef3c7; color: #92400e; }
|
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ dd { font-size: 0.9rem; }
|
|||||||
.badge-completed { background: #dcfce7; color: #166534; }
|
.badge-completed { background: #dcfce7; color: #166534; }
|
||||||
.badge-failed { background: #fee2e2; color: #991b1b; }
|
.badge-failed { background: #fee2e2; color: #991b1b; }
|
||||||
.badge-cancelled { background: #fef3c7; color: #92400e; }
|
.badge-cancelled { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-rate_limited { background: #ffedd5; color: #9a3412; }
|
||||||
|
|
||||||
.run-status { padding: 0.25rem 0; }
|
.run-status { padding: 0.25rem 0; }
|
||||||
.run-status-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
.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 title %}Domains — PhysCom{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 %}
|
{% 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 %}
|
{% else %}
|
||||||
{% for d in domains %}
|
{% for d in domains %}
|
||||||
<div class="card">
|
<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>
|
<p>{{ d.description }}</p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# HTMX partial: live status for a single pipeline run #}
|
{# HTMX partial: live status for a single pipeline run #}
|
||||||
<div class="run-status run-status-{{ run.status }}"
|
<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-get="{{ url_for('pipeline.run_status', run_id=run.id) }}"
|
||||||
hx-trigger="every 2s"
|
hx-trigger="every 2s"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
@@ -65,8 +65,14 @@
|
|||||||
<div class="flash flash-error" style="margin-top:0.5rem">{{ run.error_message }}</div>
|
<div class="flash flash-error" style="margin-top:0.5rem">{{ run.error_message }}</div>
|
||||||
{% endif %}
|
{% 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">
|
<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">
|
<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>
|
<button type="submit" class="btn btn-danger btn-sm">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% if active_runs %}
|
||||||
<h2>Active Runs</h2>
|
<h2>Active Runs</h2>
|
||||||
{% for run in active_runs %}
|
{% for run in active_runs %}
|
||||||
|
|||||||
@@ -15,7 +15,14 @@
|
|||||||
|
|
||||||
{% if domain and results is not none %}
|
{% if domain and results is not none %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div class="page-header">
|
||||||
<h2>{{ domain.name }} <span class="subtitle">{{ domain.description }}</span></h2>
|
<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 %}
|
{% if statuses %}
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
@@ -53,7 +60,7 @@
|
|||||||
{% for r in results %}
|
{% for r in results %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ loop.index }}</td>
|
<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>{{ r.combination.entities|map(attribute='name')|join(' + ') }}</td>
|
||||||
<td><span class="badge badge-{{ r.combination.status }}">{{ r.combination.status }}</span></td>
|
<td><span class="badge badge-{{ r.combination.status }}">{{ r.combination.status }}</span></td>
|
||||||
<td class="block-reason-cell">
|
<td class="block-reason-cell">
|
||||||
|
|||||||
@@ -119,3 +119,67 @@ def test_hydrogen_bicycle_valid(bicycle, hydrogen_engine):
|
|||||||
# This is actually a borderline case — let's just verify no hard physics blocks
|
# This is actually a borderline case — let's just verify no hard physics blocks
|
||||||
range_blocks = [v for v in result.violations if "mutually exclusive" in v or "atmosphere" in v]
|
range_blocks = [v for v in result.violations if "mutually exclusive" in v or "atmosphere" in v]
|
||||||
assert len(range_blocks) == 0
|
assert len(range_blocks) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_density_deficit_blocks():
|
||||||
|
"""A platform needing 2000 Wh/kg paired with a 200 Wh/kg battery → blocked."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Spaceship", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "2000", "Wh/kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
power = Entity(
|
||||||
|
name="Battery", dimension="power_source",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
combo = Combination(entities=[platform, power])
|
||||||
|
result = resolver.resolve(combo)
|
||||||
|
assert result.status == "blocked"
|
||||||
|
assert any("energy density deficit" in v for v in result.violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_density_under_density_warning():
|
||||||
|
"""A platform needing 400 Wh/kg paired with a 200 Wh/kg battery → conditional."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Airplane", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "400", "Wh/kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
power = Entity(
|
||||||
|
name="Battery", dimension="power_source",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "200", "Wh/kg", "provides"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
combo = Combination(entities=[platform, power])
|
||||||
|
result = resolver.resolve(combo)
|
||||||
|
assert result.status != "blocked"
|
||||||
|
assert any("under-density" in w for w in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_density_no_constraint_if_no_provider():
|
||||||
|
"""A platform with energy density requirement but no declared provider → no violation."""
|
||||||
|
platform = Entity(
|
||||||
|
name="Spaceship", dimension="platform",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("physical", "energy_density_wh_kg", "2000", "Wh/kg", "range_min"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Solar Sail-style: no energy_density_wh_kg declared
|
||||||
|
power = Entity(
|
||||||
|
name="Solar Sail", dimension="power_source",
|
||||||
|
dependencies=[
|
||||||
|
Dependency("force", "force_output_watts", "1", "watts", "provides"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resolver = ConstraintResolver()
|
||||||
|
combo = Combination(entities=[platform, power])
|
||||||
|
result = resolver.resolve(combo)
|
||||||
|
density_violations = [v for v in result.violations if "energy density" in v]
|
||||||
|
assert len(density_violations) == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user