Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ jobs:
- name: Validate saved evidence
run: python -m broca --root PyAutoBroca check
- name: Render dashboard without recollecting evidence
run: python -m broca --root PyAutoBroca render --brain PyAutoBrain --output site/index.html
run: >-
python -m broca --root PyAutoBroca render --brain PyAutoBrain
--output site/index.html --state-output site/state.json
--repository "$GITHUB_REPOSITORY"
- name: Validate shared organ feed
run: python PyAutoBrain/board/_state.py site/state.json
- name: Upload site only
uses: actions/upload-pages-artifact@v3
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
.venv/
preview/
*.egg-info/
site/
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ from response quality. `--import-history` imports existing runner meta/score
summaries without changing source files. Missing or conflicting evidence is
reported, not discarded. A failed/partial refresh shows unknown freshness.

## Scientist state feed

The Pages artifact includes `index.html` and the shared `state.json`. To generate
both locally without collecting evidence:

```bash
python -m broca render --brain /path/to/PyAutoBrain --output site/index.html \
--state-output site/state.json --repository YourOwner/YourCommunicationRepo \
--pages-url https://research.example/guides/
python /path/to/PyAutoBrain/board/_state.py site/state.json
```

Supply the canonical `owner/repository` identity from your Mind map or publishing
context; Pages uses `GITHUB_REPOSITORY`. No second identity map is stored here.
The board URL comes from `assistants.json`'s `pages_url`, or the explicit override.
An empty `assistants: []` supports a guides/workspaces-only instance. HTML-only
rendering remains supported. See [feed semantics](docs/state-feed.md) for clocks,
statuses and the absence of collection/evaluation claims.

## Storage and comparisons

`records/<run_id>.json` is an immutable version-1 evaluation record;
Expand Down
2 changes: 2 additions & 0 deletions assistants.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"schema_version": 1,
"board_base_url": "https://pyautolabs.github.io",
"pages_url": "https://pyautolabs.github.io/PyAutoBroca/",
"collection_max_age_days": 30,
"assistants": ["autocti_assistant", "autofit_assistant", "autogalaxy_assistant", "autolens_assistant"],
"evidence_max_age_days": 30
}
12 changes: 12 additions & 0 deletions broca/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ def main(argv=None):
board = sub.add_parser("render")
board.add_argument("--brain", type=Path, required=True)
board.add_argument("--output", type=Path, default=Path("dashboard.html"))
board.add_argument("--state-output", type=Path, help="also write the shared organ feed")
board.add_argument("--repository", help="canonical owner/repository from the publishing context")
board.add_argument("--pages-url", help="override the configured public board directory")
args = parser.parse_args(argv)
try:
names = config(args.root)["assistants"]
Expand All @@ -41,9 +44,18 @@ def main(argv=None):
return 0 if result["refreshed_at"] else 1
else:
from .board import render
state = None
if args.state_output:
if args.state_output.resolve() == args.output.resolve():
raise ValueError("HTML and state output paths must differ")
from .state import build
state = build(args.root, args.brain, args.repository, args.pages_url)
page = render(args.root, args.brain)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(page)
if state is not None:
args.state_output.parent.mkdir(parents=True, exist_ok=True)
args.state_output.write_text(json.dumps(state, indent=2) + "\n")
print(args.output)
except (ValueError, OSError, KeyError, TypeError) as exc:
print(f"Broca: {exc}", file=sys.stderr)
Expand Down
11 changes: 8 additions & 3 deletions broca/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ def evidence_state(record, row, max_age, today=None):
return "Recorded pass" if record["status"] == "passed" else "Needs interpretation"


def latest_benchmark(runs):
"""Day-precision ties retain the most conservative recorded outcome."""
latest_day = max((r["observed_at"][:10] for r in runs), default=None)
return max((r for r in runs if r["observed_at"][:10] == latest_day),
key=lambda r: {"passed": 0, "unscored": 1, "failed": 2}[r["status"]], default=None)


def render(root, brain):
theme = theme_from(brain)
cfg = config(root)
Expand All @@ -52,9 +59,7 @@ def render(root, brain):
key=lambda r: (r["observed_at"], r["run_id"]))
# Date-only imports cannot establish an order within a day. Show the
# most conservative outcome from the latest day, not a random run ID.
latest_day = max((r["observed_at"][:10] for r in runs), default=None)
latest = max((r for r in runs if r["observed_at"][:10] == latest_day),
key=lambda r: {"passed": 0, "unscored": 1, "failed": 2}[r["status"]], default=None)
latest = latest_benchmark(runs)
state = evidence_state(latest, row, cfg["evidence_max_age_days"])
collected = row.get("status", "unavailable")
errors = row.get("errors", [])
Expand Down
2 changes: 1 addition & 1 deletion broca/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def git(repo, *args):
def config(root):
data = json.loads((Path(root) / "assistants.json").read_text())
names = data.get("assistants", [])
if data.get("schema_version") != 1 or not names or len(names) != len(set(names)):
if data.get("schema_version") != 1 or not isinstance(names, list) or len(names) != len(set(names)):
raise ValueError("invalid assistant configuration")
if any(not isinstance(n, str) or not re.fullmatch(r"[A-Za-z0-9_]+", n) for n in names):
raise ValueError("invalid assistant name")
Expand Down
97 changes: 97 additions & 0 deletions broca/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Broca's shared feed: saved owner evidence, never a new collection or run."""
from datetime import datetime, timedelta, timezone
import importlib.util
import json
from pathlib import Path
import re
from urllib.parse import urlsplit

from .board import evidence_state, latest_benchmark
from .collect import config, load_snapshot
from .records import load_records

UNKNOWN_TIME = "1970-01-01T00:00:00Z" # v1 requires a timestamp even when no receipt exists.


def shared_contract(brain):
spec = importlib.util.spec_from_file_location("broca_shared_state", Path(brain) / "board/_state.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def pages_directory(value):
if not isinstance(value, str):
raise ValueError("configure pages_url or supply --pages-url")
url = urlsplit(value)
if (url.scheme != "https" or not url.hostname or url.username or url.password
or url.query or url.fragment or "\\" in value or any(c.isspace() for c in value)):
raise ValueError("pages_url must be an explicit public HTTPS directory")
return value.rstrip("/") + "/"


def build(root, brain, repository, pages_url=None, *, now=None):
"""Repository comes from canonical publishing context, not the checkout name."""
if not isinstance(repository, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
raise ValueError("--repository must be the canonical owner/repository identity")
cfg = config(root)
pages = pages_directory(pages_url or cfg.get("pages_url"))
max_age = cfg.get("collection_max_age_days", 30)
if type(max_age) is not int or max_age < 1:
raise ValueError("collection_max_age_days must be a positive integer")
contract = shared_contract(brain)
snapshot = load_snapshot(root)
records = load_records(root, cfg["assistants"])
now = now or datetime.now(timezone.utc)
attempted = snapshot.get("attempted_at")
updated = attempted or UNKNOWN_TIME
if not contract._is_utc_iso(updated):
raise ValueError("collection attempt must have a full UTC timestamp")
expires = (datetime.fromisoformat(updated.replace("Z", "+00:00")) + timedelta(days=max_age)) if attempted else None
valid_until = expires
complete = snapshot.get("refreshed_at") is not None
items = [{"severity": "info" if complete else "yellow", "text":
("Collection: " + snapshot["refreshed_at"]) if complete else
("Collection incomplete; attempt " + attempted if attempted else "Collection unavailable; timestamp unknown"),
"url": pages + "#maintenance"}]
catalogue_path = Path(root) / "resources.json"
if catalogue_path.exists():
catalogue = json.loads(catalogue_path.read_text())
if not isinstance(catalogue, dict) or catalogue.get("schema_version") != 1 or not isinstance(catalogue.get("resources"), list):
raise ValueError("invalid public resource catalogue")
communication = {"workspaces", "tutorials", "documentation"}
count = 0
for resource in catalogue["resources"]:
if not isinstance(resource, dict) or not isinstance(resource.get("relations"), list) or any(not isinstance(k, str) for k in resource["relations"]):
raise ValueError("invalid public resource relations")
if communication.intersection(resource["relations"]) and "assistants" not in resource["relations"]:
count += 1
text = f"Guides & Workspaces: {count} resources (inventory)"
else:
text = "Guides & Workspaces: inventory unavailable"
items.append({"severity": "info", "text": text, "url": pages + "#communication"})
rows = {row["assistant"]: row for row in snapshot["assistants"]}
states = []
for name in cfg["assistants"]:
latest = latest_benchmark([r for r in records if r["assistant"] == name and r["kind"] == "benchmark"])
state = evidence_state(latest, rows.get(name, {}), cfg["evidence_max_age_days"], today=now.date())
states.append(state)
if state == "Recorded pass":
# evidence_state uses whole UTC calendar days: age == max is still
# current; it becomes outdated at the next midnight. A newer
# collection must not extend a passing benchmark's validity.
deadline = (datetime.fromisoformat(latest["observed_at"][:10]).replace(tzinfo=timezone.utc)
+ timedelta(days=cfg["evidence_max_age_days"] + 1))
valid_until = min(valid_until, deadline) if valid_until else deadline
severity = "red" if state == "Recorded failure" else "info" if state in {"Recorded pass", "Never evaluated"} else "yellow"
observed = f" · observed {latest['observed_at']}" if latest else ""
items.append({"severity": severity, "text": f"{name}: {state}{observed}", "url": pages + "#assistants"})
stale = expires is not None and now > expires
if stale:
items.append({"severity": "yellow", "text": "Collection evidence outdated", "url": pages + "#maintenance"})
status = ("red" if "Recorded failure" in states else "stale" if stale else
"yellow" if attempted and (not complete or any(i["severity"] == "yellow" for i in items)) else
"green" if complete and states and all(s == "Recorded pass" for s in states) else "grey")
return contract.build_state("broca", repository.split("/")[1], status,
"Guides & Workspaces · assistant evidence", updated, pages, items,
valid_until=valid_until.isoformat() if valid_until else None)
59 changes: 59 additions & 0 deletions docs/state-feed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Broca state feed

`render --state-output` builds Brain's shared version-1 contract using the selected
Brain checkout's `board/_state.py::build_state`. Scientist's collector and
`connect_organ.py` consume this contract without Broca-specific parsing. Identity
comes from explicit `--repository owner/repository`; `repo` in the feed is the
repository basename required by that contract. `pages_url` is independently
configured and must be an HTTPS directory. Declared identity is not access proof.

The artifact contains only rendered HTML and JSON. It never includes raw receipts,
assistant transcripts, project source or the whole checkout. Rendering does not
collect, evaluate, publish by itself, or rewrite the append-only evidence store.

## Clocks

- `updated` is the saved latest collection **attempt**, whether complete or
incomplete. The collection row distinguishes the successful `refreshed_at`
timestamp from a failed/incomplete attempt.
- With no receipt, v1's required timestamp uses `1970-01-01T00:00:00Z` as an
explicit **unknown sentinel**, not an observation. The row states timestamp
unknown and the feed cannot be green. No `valid_until` is emitted.
- `valid_until` is bounded by the attempt timestamp plus
`collection_max_age_days` (default 30), and by the earliest expiry of any
currently passing benchmark. Evaluation expiry uses the same UTC calendar-day
rule as the dashboard: midnight after `evidence_max_age_days` whole days. A
newer collection cannot extend a passing benchmark's validity. The collection
policy remains separate from the evaluation-age policy.
- Each assistant row retains the latest benchmark's original observation date.
A new collection cannot freshen an old evaluation. Rendering never changes any
of these dates. No inventory timestamp or quality measurement is inferred for
the undated guides/workspaces catalogue.

## Status meaning

The same conservative latest-day benchmark selection and `evidence_state`
classification serve HTML and JSON. Maintenance/inventory records are not
benchmark passes. Same-day failure wins over pass because day-only timestamps
cannot establish ordering.

The aggregate reports recorded failure as red; otherwise expired collection is
stale, incomplete collection or evidence needing interpretation is yellow, and
all configured assistants having current recorded passes after complete collection
is green. Missing collection, no assistants, or only never-evaluated assistants
is grey. Items preserve the distinct reasons even when one aggregate status
takes priority. Green describes recorded assistant benchmarks only; it does not
certify all communication resources or scientific correctness.

Guides, HowTo tutorials and workspaces remain an informational inventory count,
separate from assistant evaluation rows. Their projects own source and meanings.
Ears listening, Memory knowledge, Cortex science, Heart readiness and Hands
release responsibilities are unchanged. The feed contains no executable actions
and does not create a new task ledger.

Malformed/digest-mismatched current receipts, invalid records, invalid catalogue
shapes and invalid publishing configuration fail rendering. The existing Pages
check also validates historical receipts. The job validates the generated feed
before uploading the site; deployment runs only on main. Validate PR artifacts
before merge, then verify the actual HTTPS endpoint after successful deployment
before claiming a live Scientist connection.
3 changes: 3 additions & 0 deletions tests/test_pages_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ def test_pages_publishes_only_rendered_snapshot_with_guarded_deployment():
commands = '\n'.join(step.get('run', '') for step in build['steps'])
assert 'broca --root PyAutoBroca check' in commands
assert 'render --brain PyAutoBrain --output site/index.html' in commands
assert '--state-output site/state.json' in commands
assert '--repository "$GITHUB_REPOSITORY"' in commands
assert 'python PyAutoBrain/board/_state.py site/state.json' in commands
assert 'refresh' not in commands and '--pilot' not in commands
upload = next(step for step in build['steps'] if step.get('uses', '').startswith('actions/upload-pages-artifact@'))
assert upload['with']['path'] == 'site'
Expand Down
Loading
Loading