Skip to content

Commit 21e8ed1

Browse files
committed
feat: context-rich dashboard (intro, tooltips, glossary, per-incident reasoning + evidence)
1 parent 87f2116 commit 21e8ed1

7 files changed

Lines changed: 181 additions & 46 deletions

File tree

agentwatch/api/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def _classification_out(cls) -> ClassificationOut | None:
2323
severity=cls.severity,
2424
confidence=cls.confidence,
2525
abstained=cls.abstained,
26+
reasoning_summary=cls.reasoning_summary,
2627
model_name=cls.model_name,
2728
prompt_version=cls.prompt_version,
2829
)

agentwatch/api/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class ClassificationOut(BaseModel):
1010
severity: int | None
1111
confidence: float
1212
abstained: bool
13+
reasoning_summary: str | None = None
1314
model_name: str
1415
prompt_version: str
1516

dashboard/app.py

Lines changed: 179 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,193 @@
11
from dashboard.client import AgentWatchClient
22

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+
}
317

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+
)
625

7-
st.set_page_config(page_title="AgentWatch", layout="wide")
8-
st.title("AgentWatch — AI Incident Observatory")
926

10-
api = AgentWatchClient()
27+
def _sidebar(st):
28+
st.sidebar.title("🔭 AgentWatch")
29+
st.sidebar.caption("Observatory for public AI-agent incidents")
1130
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
1243

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"]:
2069
st.bar_chart(stats["by_incident_type"])
70+
else:
71+
st.info("No incidents yet.")
72+
_type_glossary(st)
2173

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-
)
4074

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)
41189
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)
58191

59192

60193
if __name__ == "__main__":

docs/assets/demo.gif

149 KB
Loading
107 KB
Loading
90.1 KB
Loading
42.8 KB
Loading

0 commit comments

Comments
 (0)