|
1 | 1 | from dashboard.client import AgentWatchClient |
2 | 2 |
|
| 3 | +# Plain-language descriptions of each incident category, shown in the UI so a |
| 4 | +# first-time visitor understands what the labels mean. |
| 5 | +INCIDENT_TYPES = { |
| 6 | + "unauthorized_action": "The agent took an action it wasn't authorised to take.", |
| 7 | + "resistance_to_correction": "The agent ignored or resisted instructions to stop.", |
| 8 | + "deception": "The agent misrepresented what it did or was doing.", |
| 9 | + "goal_persistence": "The agent kept pursuing a goal after it should have stopped.", |
| 10 | + "privilege_escalation": "The agent gained access or permissions beyond what it was given.", |
| 11 | + "sandbox_escape": "The agent broke out of its intended environment.", |
| 12 | + "destructive_action": "The agent deleted, overwrote, or destroyed something.", |
| 13 | + "resource_acquisition": "The agent acquired money, compute, or other resources.", |
| 14 | + "harmless_malfunction": "A minor glitch with no real harm.", |
| 15 | + "insufficient_evidence": "Not enough information to decide — the classifier abstained.", |
| 16 | +} |
3 | 17 |
|
4 | | -def render() -> None: |
5 | | - import streamlit as st |
| 18 | +INTRO = ( |
| 19 | + "**AgentWatch** collects public reports of AI-agent incidents (an agent deleting " |
| 20 | + "files, ignoring instructions, acting without permission, behaving deceptively…), " |
| 21 | + "preserves each as tamper-evident evidence, and **classifies** it into an incident " |
| 22 | + "type. Machine labels are treated as opinions — a human **review** step can accept, " |
| 23 | + "override, or reject them." |
| 24 | +) |
6 | 25 |
|
7 | | - st.set_page_config(page_title="AgentWatch", layout="wide") |
8 | | - st.title("AgentWatch — AI Incident Observatory") |
9 | 26 |
|
10 | | - api = AgentWatchClient() |
| 27 | +def _sidebar(st): |
| 28 | + st.sidebar.title("🔭 AgentWatch") |
| 29 | + st.sidebar.caption("Observatory for public AI-agent incidents") |
11 | 30 | page = st.sidebar.radio("View", ["Overview", "Incident Explorer", "Review Queue"]) |
| 31 | + st.sidebar.markdown("---") |
| 32 | + with st.sidebar.expander("ℹ️ About this dashboard", expanded=False): |
| 33 | + st.markdown( |
| 34 | + "This is a **demo instance**. Incidents are collected from public sources " |
| 35 | + "(Hacker News and a bundled sample set) and classified automatically by a " |
| 36 | + "pluggable classifier.\n\n" |
| 37 | + "**Classifications are automated and unverified** — the Review Queue is where " |
| 38 | + "a human checks them.\n\n" |
| 39 | + "- [Documentation](https://kohsheen1234.github.io/Open-source-AI-Incident-Observatory/)\n" |
| 40 | + "- [Source code](https://github.com/kohsheen1234/Open-source-AI-Incident-Observatory)" |
| 41 | + ) |
| 42 | + return page |
12 | 43 |
|
13 | | - if page == "Overview": |
14 | | - stats = api.stats() |
15 | | - col1, col2, col3 = st.columns(3) |
16 | | - col1.metric("Incidents", stats["total_incidents"]) |
17 | | - col2.metric("Classified", stats["total_classified"]) |
18 | | - col3.metric("Abstention rate", f"{stats['abstention_rate']:.0%}") |
19 | | - st.subheader("By incident type") |
| 44 | + |
| 45 | +def _type_glossary(st): |
| 46 | + with st.expander("What do these incident types mean?"): |
| 47 | + for name, desc in INCIDENT_TYPES.items(): |
| 48 | + st.markdown(f"- **{name}** — {desc}") |
| 49 | + |
| 50 | + |
| 51 | +def _render_overview(st, api): |
| 52 | + st.subheader("Overview") |
| 53 | + st.markdown(INTRO) |
| 54 | + stats = api.stats() |
| 55 | + col1, col2, col3 = st.columns(3) |
| 56 | + col1.metric("Incidents", stats["total_incidents"], |
| 57 | + help="Public posts collected as potential AI-agent incidents.") |
| 58 | + col2.metric("Classified", stats["total_classified"], |
| 59 | + help="Incidents the classifier has labelled.") |
| 60 | + col3.metric("Abstention rate", f"{stats['abstention_rate']:.0%}", |
| 61 | + help="Share the classifier marked 'insufficient evidence' rather than guessing.") |
| 62 | + |
| 63 | + st.markdown("### Incidents by type") |
| 64 | + st.caption( |
| 65 | + "How many incidents fall into each category. `insufficient_evidence` means the " |
| 66 | + "classifier abstained instead of guessing." |
| 67 | + ) |
| 68 | + if stats["by_incident_type"]: |
20 | 69 | st.bar_chart(stats["by_incident_type"]) |
| 70 | + else: |
| 71 | + st.info("No incidents yet.") |
| 72 | + _type_glossary(st) |
21 | 73 |
|
22 | | - elif page == "Incident Explorer": |
23 | | - incident_type = st.sidebar.text_input("Incident type filter") or None |
24 | | - data = api.incidents(incident_type=incident_type, limit=200) |
25 | | - st.caption(f"{data['total']} incidents") |
26 | | - st.dataframe( |
27 | | - [ |
28 | | - { |
29 | | - "id": item["id"], |
30 | | - "source": item["source"], |
31 | | - "title": item["title"], |
32 | | - "type": (item["classification"] or {}).get("incident_type"), |
33 | | - "severity": (item["classification"] or {}).get("severity"), |
34 | | - "confidence": (item["classification"] or {}).get("confidence"), |
35 | | - } |
36 | | - for item in data["items"] |
37 | | - ], |
38 | | - use_container_width=True, |
39 | | - ) |
40 | 74 |
|
| 75 | +def _classification_caption(cls: dict) -> str: |
| 76 | + if not cls: |
| 77 | + return "Not yet classified." |
| 78 | + conf = cls.get("confidence") |
| 79 | + conf_s = f"{conf:.0%}" if isinstance(conf, (int, float)) else "—" |
| 80 | + return ( |
| 81 | + f"**{cls.get('incident_type')}** · severity {cls.get('severity') or '—'} · " |
| 82 | + f"confidence {conf_s} · model `{cls.get('model_name')}`" |
| 83 | + ) |
| 84 | + |
| 85 | + |
| 86 | +def _render_explorer(st, api): |
| 87 | + st.subheader("Incident Explorer") |
| 88 | + st.markdown( |
| 89 | + "Every collected incident with its most recent classification. **Each row is a " |
| 90 | + "real public post.** Use the filter to focus on one incident type." |
| 91 | + ) |
| 92 | + incident_type = st.sidebar.selectbox( |
| 93 | + "Filter by incident type", ["(all)", *INCIDENT_TYPES.keys()] |
| 94 | + ) |
| 95 | + ftype = None if incident_type == "(all)" else incident_type |
| 96 | + data = api.incidents(incident_type=ftype, limit=200) |
| 97 | + st.caption(f"Showing {len(data['items'])} of {data['total']} incidents.") |
| 98 | + st.dataframe( |
| 99 | + [ |
| 100 | + { |
| 101 | + "id": item["id"], |
| 102 | + "source": item["source"], |
| 103 | + "title": item["title"], |
| 104 | + "type": (item["classification"] or {}).get("incident_type"), |
| 105 | + "severity": (item["classification"] or {}).get("severity"), |
| 106 | + "confidence": (item["classification"] or {}).get("confidence"), |
| 107 | + "link": item["url"], |
| 108 | + } |
| 109 | + for item in data["items"] |
| 110 | + ], |
| 111 | + use_container_width=True, |
| 112 | + hide_index=True, |
| 113 | + ) |
| 114 | + |
| 115 | + st.markdown("### 🔎 Inspect an incident") |
| 116 | + st.caption("Pick an incident to see the original evidence and why it was classified.") |
| 117 | + options = {f"#{i['id']} — {i['title'][:70]}": i["id"] for i in data["items"]} |
| 118 | + if options: |
| 119 | + label = st.selectbox("Incident", list(options), key="explore") |
| 120 | + _show_detail(st, api, options[label]) |
| 121 | + |
| 122 | + |
| 123 | +def _show_detail(st, api, incident_id: int): |
| 124 | + detail = api.incident(incident_id) |
| 125 | + st.markdown(f"**{detail.get('title', '')}**") |
| 126 | + if detail.get("url"): |
| 127 | + st.markdown(f"[View original post]({detail['url']})") |
| 128 | + cls = detail.get("classification") or {} |
| 129 | + st.markdown(_classification_caption(cls)) |
| 130 | + if cls.get("reasoning_summary"): |
| 131 | + st.info(f"Classifier reasoning: {cls['reasoning_summary']}") |
| 132 | + with st.expander("Evidence (original text)"): |
| 133 | + st.write(detail.get("body") or "(no body text)") |
| 134 | + |
| 135 | + |
| 136 | +def _render_review(st, api): |
| 137 | + st.subheader("Review Queue") |
| 138 | + st.markdown( |
| 139 | + "Human-in-the-loop review. The classifier's label is a machine **opinion**; here " |
| 140 | + "a person **accepts**, **overrides**, or flags it as a **false positive**. Both the " |
| 141 | + "machine label and the human decision are kept, so the classifier's accuracy can " |
| 142 | + "be measured over time." |
| 143 | + ) |
| 144 | + data = api.incidents(limit=200) |
| 145 | + options = {f"#{i['id']} — {i['title'][:70]}": i["id"] for i in data["items"]} |
| 146 | + if not options: |
| 147 | + st.info("No incidents yet. Seed some with `agentwatch collect` and `agentwatch classify`.") |
| 148 | + return |
| 149 | + label = st.selectbox("Incident to review", list(options)) |
| 150 | + incident_id = options[label] |
| 151 | + detail = api.incident(incident_id) |
| 152 | + |
| 153 | + st.markdown("#### Evidence") |
| 154 | + if detail.get("url"): |
| 155 | + st.markdown(f"[View original post]({detail['url']})") |
| 156 | + st.write(detail.get("body") or "(no body text)") |
| 157 | + |
| 158 | + st.markdown("#### Machine classification") |
| 159 | + cls = detail.get("classification") or {} |
| 160 | + st.markdown(_classification_caption(cls)) |
| 161 | + if cls.get("reasoning_summary"): |
| 162 | + st.info(f"Classifier reasoning: {cls['reasoning_summary']}") |
| 163 | + |
| 164 | + st.markdown("#### Your review") |
| 165 | + reviewer = st.text_input("Reviewer", value="reviewer") |
| 166 | + decision = st.selectbox( |
| 167 | + "Decision", |
| 168 | + ["accept", "override", "false_positive"], |
| 169 | + help="accept = label is right · override = wrong type · false_positive = not an incident", |
| 170 | + ) |
| 171 | + notes = st.text_area("Notes (optional)") |
| 172 | + if st.button("Submit review"): |
| 173 | + api.review(incident_id, reviewer=reviewer, decision=decision, notes=notes or None) |
| 174 | + st.success("Review saved. It's now attached to this incident's classification.") |
| 175 | + |
| 176 | + |
| 177 | +def render() -> None: |
| 178 | + import streamlit as st |
| 179 | + |
| 180 | + st.set_page_config(page_title="AgentWatch — AI Incident Observatory", layout="wide") |
| 181 | + api = AgentWatchClient() |
| 182 | + page = _sidebar(st) |
| 183 | + st.title("AgentWatch — AI Incident Observatory") |
| 184 | + |
| 185 | + if page == "Overview": |
| 186 | + _render_overview(st, api) |
| 187 | + elif page == "Incident Explorer": |
| 188 | + _render_explorer(st, api) |
41 | 189 | elif page == "Review Queue": |
42 | | - data = api.incidents(limit=200) |
43 | | - options = {f"#{i['id']} — {i['title'][:60]}": i["id"] for i in data["items"]} |
44 | | - if not options: |
45 | | - st.info("No incidents yet. Run `agentwatch collect` and `agentwatch classify`.") |
46 | | - return |
47 | | - label = st.selectbox("Incident", list(options)) |
48 | | - incident_id = options[label] |
49 | | - detail = api.incident(incident_id) |
50 | | - st.write(detail.get("body", "")) |
51 | | - st.json(detail.get("classification") or {}) |
52 | | - reviewer = st.text_input("Reviewer", value="reviewer") |
53 | | - decision = st.selectbox("Decision", ["accept", "override", "false_positive"]) |
54 | | - notes = st.text_area("Notes") |
55 | | - if st.button("Submit review"): |
56 | | - api.review(incident_id, reviewer=reviewer, decision=decision, notes=notes or None) |
57 | | - st.success("Review saved.") |
| 190 | + _render_review(st, api) |
58 | 191 |
|
59 | 192 |
|
60 | 193 | if __name__ == "__main__": |
|
0 commit comments