From fb88630cd88f8d2b186281bcc9ea81fe44fdde85 Mon Sep 17 00:00:00 2001 From: "hans.r7" Date: Tue, 10 Nov 2026 15:56:56 -0600 Subject: [PATCH 1/3] Improve reviewer workflow and readability --- .streamlit/config.toml | 10 +++ app.py | 99 ++++++++++++++++++++++++----- data/questions.json | 3 - src/proofstack/storage/__init__.py | 11 ++++ src/proofstack/storage/firestore.py | 13 ++++ src/proofstack/storage/local.py | 11 ++++ 6 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 .streamlit/config.toml diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..8710965 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,10 @@ +[theme] +base = "light" +primaryColor = "#176b5b" +backgroundColor = "#f4f6f8" +secondaryBackgroundColor = "#ffffff" +textColor = "#17212b" +font = "sans serif" + +[server] +headless = true diff --git a/app.py b/app.py index 3e396c0..265d1a9 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import csv +import html import json import os import sys @@ -15,7 +16,27 @@ from proofstack.parser import parse_model_output from proofstack.providers import get_provider from proofstack.review_state import assessment_matches_case, reviewer_submission_errors -from proofstack.storage import save_decision +from proofstack.storage import load_decisions, save_decision + +st.markdown(""" + +""", unsafe_allow_html=True) DECISION_LABELS = { @@ -36,6 +57,13 @@ def load_manifest(): questions = load_questions() manifest = load_manifest() q_by_id = {q["question_id"]: q for q in questions} +try: + saved_decisions = load_decisions() + storage_read_error = "" +except Exception as exc: + saved_decisions = [] + storage_read_error = str(exc) +latest_decision = {row.get("evidence_id"): row for row in saved_decisions} st.set_page_config(page_title="ProofStack", page_icon="🧾", layout="wide") st.title("ProofStack") @@ -45,20 +73,39 @@ def load_manifest(): ) with st.sidebar: - st.header("Reviewer checklist") - st.write("1. Read the claim.") - st.write("2. Inspect the evidence.") - st.write("3. Run the AI assessment.") - st.write("4. Verify the cited excerpt.") - st.write("5. Record your decision.") + st.header("Review queue") + st.caption("Claim → evidence → advisory → your decision") + done_docs = len(set(latest_decision) & {row["evidence_id"] for row in manifest}) + st.metric("Documents decided", f"{done_docs} / {len(manifest)}") + status_filter = st.radio("Show claims", ["All", "Needs review", "Completed"], horizontal=True) st.divider() st.caption( "Synthetic evidence only. ProofStack is a review aid and does not certify compliance." ) +question_ids = [q["question_id"] for q in questions] +if storage_read_error: + st.sidebar.warning(f"Could not load decision history: {storage_read_error}") +if status_filter != "All": + question_ids = [qid for qid in question_ids if (lambda docs: bool(docs) and (all(doc["evidence_id"] in latest_decision for doc in docs) == (status_filter == "Completed")))([row for row in manifest if row["question_id"] == qid])] +if not question_ids: + question_ids = [q["question_id"] for q in questions] + +with st.expander("Decision history", expanded=False): + st.subheader("Reviewer decision history") + st.caption("Each entry preserves the reviewer label, advisory context, and evidence reference at the time of review.") + if saved_decisions: + st.dataframe( + [{"Recorded (UTC)": r.get("timestamp_utc", ""), "Claim": r.get("question_id", ""), "Document": r.get("evidence_title", r.get("evidence_id", "")), "Reviewer decision": r.get("reviewer_label", ""), "Advisory": r.get("model_label", "—") or "—", "Reviewer": r.get("reviewer", ""), "Notes": r.get("notes", "")} for r in reversed(saved_decisions)], + width="stretch", + hide_index=True, + ) + else: + st.info("No reviewer decisions have been recorded yet. Saved decisions will appear here.") + q_id = st.selectbox( "Security question", - [q["question_id"] for q in questions], + question_ids, format_func=lambda value: ( f'{value} — ' f'{q_by_id[value].get("reviewer_friendly_question") or q_by_id[value]["question"]}' @@ -72,12 +119,14 @@ def load_manifest(): claim_col, guidance_col = st.columns([3, 2]) with claim_col: st.markdown("**Claim to verify**") - st.info(question["claim"]) + st.markdown(f'
{html.escape(question["claim"])}
', unsafe_allow_html=True) with guidance_col: st.markdown("**What good evidence looks like**") st.write(question.get("reviewer_hint", "")) mapped = [item for item in manifest if item["question_id"] == q_id] +reviewed_for_claim = sum(item["evidence_id"] in latest_decision for item in mapped) +st.caption(f"{reviewed_for_claim}/{len(mapped)} documents decided · review each document against the claim") e_id = st.selectbox( "Submitted evidence", [item["evidence_id"] for item in mapped], @@ -89,6 +138,9 @@ def load_manifest(): ) evidence = next(item for item in mapped if item["evidence_id"] == e_id) +previous_decision = latest_decision.get(e_id) +if previous_decision: + st.info(f"Previous decision: {previous_decision.get('reviewer_label','')} by {previous_decision.get('reviewer','reviewer')} · {previous_decision.get('timestamp_utc','')}. Recording again appends a revision.") evidence_path = ROOT / evidence["path"] evidence_text = evidence_path.read_text(encoding="utf-8") @@ -120,10 +172,15 @@ def load_manifest(): st.text(evidence_text) with right: - st.subheader("AI recommendation") - st.caption("Advisory only. Verify the evidence and citation before deciding.") + st.subheader("Model advisory") + st.caption("Advisory only · reviewer makes the decision") + active_provider = os.getenv("PROOFSTACK_PROVIDER", "mock").strip().lower() + if active_provider == "mock": + st.info("Source: Local mock · deterministic heuristic, not an AI model") + else: + st.info(f"Source: configured AI provider · {active_provider}") - if st.button("Assess this evidence", type="primary", use_container_width=True): + if st.button("Assess this evidence", type="primary", width="stretch"): try: provider = get_provider() raw = provider.assess(question, evidence, evidence_text) @@ -189,9 +246,9 @@ def load_manifest(): st.caption("Run the assessment when you are ready to compare the claim with this evidence.") st.divider() -st.subheader("Reviewer decision") +st.subheader("Your decision for this document") st.caption( - "Your decision is authoritative. Use the model as a second opinion, not as an approval engine." + "Decide whether this document supports the claim. Revisions add a new record; earlier decisions remain in the log." ) reviewer = st.text_input("Reviewer name") @@ -211,7 +268,7 @@ def load_manifest(): if reviewer_label != assessment["label"]: st.info("Your decision differs from the AI recommendation. Explain why in reviewer notes.") -if st.button("Record final decision", use_container_width=True): +if st.button("Record reviewer decision", width="stretch"): errors = reviewer_submission_errors( reviewer, reviewer_label, @@ -241,9 +298,17 @@ def load_manifest(): try: record_id = save_decision(row) + st.session_state["last_saved_case"] = f"{q_id}/{e_id}" + st.session_state["last_saved_at"] = row["timestamp_utc"] if record_id: - st.success(f"Decision recorded. Record ID: {record_id}") + st.success(f"Decision saved to the review log · Record {record_id}") else: - st.success("Decision recorded.") + st.success("Decision saved to the review log. Any later decision is recorded as a new entry.") except Exception as exc: st.error(str(exc)) + +with st.expander("How to review this case", expanded=not st.session_state.get("review_guide_seen", False)): + st.markdown("**Start with the claim.** Check its scope, systems, people, and time period.") + st.markdown("**Inspect what the document demonstrates.** A policy describes intent; an operating record shows an event or current state.") + st.markdown("**Verify the citation, then decide.** A grounded excerpt can still be insufficient for the claim. Your recorded decision is authoritative.") + st.session_state["review_guide_seen"] = True diff --git a/data/questions.json b/data/questions.json index d986513..3df8bc4 100644 --- a/data/questions.json +++ b/data/questions.json @@ -1,4 +1,3 @@ - [ { "question_id": "Q01", @@ -71,5 +70,3 @@ "reviewer_hint": "Examine active third-party administrative access records. Verify documented approval, enforced MFA, and a specific access expiration date and time for each grant. All three security requirements must be supported by evidence." } ] - -] diff --git a/src/proofstack/storage/__init__.py b/src/proofstack/storage/__init__.py index 05b5135..c1a0eb8 100644 --- a/src/proofstack/storage/__init__.py +++ b/src/proofstack/storage/__init__.py @@ -9,3 +9,14 @@ def save_decision(row: dict): else: raise ValueError(f"Unknown PROOFSTACK_STORAGE: {mode}") return append_decision(row) + + +def load_decisions(): + mode = os.getenv("PROOFSTACK_STORAGE", "local").lower() + if mode == "local": + from proofstack.storage.local import read_decisions + elif mode == "firestore": + from proofstack.storage.firestore import read_decisions + else: + raise ValueError(f"Unknown PROOFSTACK_STORAGE: {mode}") + return read_decisions() diff --git a/src/proofstack/storage/firestore.py b/src/proofstack/storage/firestore.py index 2cb9027..e5b0725 100644 --- a/src/proofstack/storage/firestore.py +++ b/src/proofstack/storage/firestore.py @@ -16,3 +16,16 @@ def append_decision(row: dict): _, document_ref = client.collection(collection).add(record) return document_ref.id + + +def read_decisions(): + """Return reviewer records newest first for the in-app audit view.""" + project = os.getenv("GCP_PROJECT_ID") or None + collection = os.getenv("FIRESTORE_COLLECTION", "proofstack_decisions").strip() + if not collection: + raise RuntimeError("FIRESTORE_COLLECTION must not be empty") + client = firestore.Client(project=project) + return [ + {**snapshot.to_dict(), "record_id": snapshot.id} + for snapshot in client.collection(collection).order_by("timestamp_utc", direction=firestore.Query.DESCENDING).stream() + ] diff --git a/src/proofstack/storage/local.py b/src/proofstack/storage/local.py index d2c2941..77ae4a8 100644 --- a/src/proofstack/storage/local.py +++ b/src/proofstack/storage/local.py @@ -38,3 +38,14 @@ def append_decision(row: dict, path="local_decisions.csv"): if new: writer.writeheader() writer.writerow({key: row.get(key, "") for key in HEADER}) + + +def read_decisions(path="local_decisions.csv"): + target = Path(path) + if not target.exists(): + return [] + with target.open("r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + if reader.fieldnames != HEADER: + raise RuntimeError("Existing local decision log uses an older schema.") + return list(reader) From 306b382f1d1b511b2117542a2fd85b79a799921f Mon Sep 17 00:00:00 2001 From: jtflack-grc <202126308+jtflack-grc@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:20:32 -0500 Subject: [PATCH 2/3] Fix Streamlit page configuration ordering --- app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 265d1a9..2c2bc86 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,8 @@ from proofstack.review_state import assessment_matches_case, reviewer_submission_errors from proofstack.storage import load_decisions, save_decision +st.set_page_config(page_title="ProofStack", page_icon="🧾", layout="wide") + st.markdown(""" -""", unsafe_allow_html=True) - DECISION_LABELS = { "SUPPORTED": "SUPPORTED — the submitted evidence proves the claim", @@ -120,7 +99,7 @@ def load_manifest(): claim_col, guidance_col = st.columns([3, 2]) with claim_col: st.markdown("**Claim to verify**") - st.markdown(f'
{html.escape(question["claim"])}
', unsafe_allow_html=True) + st.info(question["claim"]) with guidance_col: st.markdown("**What good evidence looks like**") st.write(question.get("reviewer_hint", ""))