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
10 changes: 10 additions & 0 deletions .streamlit/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[theme]
base = "light"
primaryColor = "#176b5b"
backgroundColor = "#f4f6f8"
secondaryBackgroundColor = "#ffffff"
textColor = "#17212b"
font = "sans serif"

[server]
headless = true
79 changes: 62 additions & 17 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -36,29 +38,54 @@ 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. "
"The AI gives a recommendation; the reviewer makes the decision."
)

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"]}'
Expand All @@ -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],
Expand All @@ -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")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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
3 changes: 0 additions & 3 deletions data/questions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

[
{
"question_id": "Q01",
Expand Down Expand Up @@ -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."
}
]

]
11 changes: 11 additions & 0 deletions src/proofstack/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
13 changes: 13 additions & 0 deletions src/proofstack/storage/firestore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
]
11 changes: 11 additions & 0 deletions src/proofstack/storage/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading