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..0506dbe 100644 --- a/app.py +++ b/app.py @@ -15,7 +15,9 @@ 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.set_page_config(page_title="ProofStack", page_icon="🧾", layout="wide") DECISION_LABELS = { @@ -36,8 +38,14 @@ 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") st.caption( "Review a security claim against the evidence submitted for it. " @@ -45,20 +53,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"]}' @@ -78,6 +105,8 @@ def load_manifest(): 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 +118,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 +152,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 +226,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 +248,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 +278,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)