Skip to content

Commit 8889ab4

Browse files
committed
feat(api): add scorecard extraction and persistence
Add helprs-scorecard JSON block extraction from Claude session output. New scorecard.py module parses the fenced code block, validates structure (3 dimensions, 0-10 range). Scorecard is persisted to container_sessions table after mark_completed. New GET /sessions/{id}/scorecard endpoint.
1 parent e46c070 commit 8889ab4

6 files changed

Lines changed: 252 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Add scorecard and xp_earned columns to container_sessions.
2+
3+
Revision ID: a2b3c4d5e6f8
4+
Revises: a1b2c3d4e5f7
5+
Create Date: 2026-04-20
6+
"""
7+
8+
import sqlalchemy as sa
9+
from alembic import op
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision: str = "a2b3c4d5e6f8"
14+
down_revision: str | None = "a1b2c3d4e5f7"
15+
branch_labels: tuple[str, ...] | None = None
16+
depends_on: tuple[str, ...] | None = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column("container_sessions", sa.Column("scorecard", postgresql.JSONB(), nullable=True))
21+
op.add_column("container_sessions", sa.Column("xp_earned", sa.Integer(), nullable=True))
22+
23+
24+
def downgrade() -> None:
25+
op.drop_column("container_sessions", "xp_earned")
26+
op.drop_column("container_sessions", "scorecard")

apps/api/src/helprs/modules/container/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ class ContainerSession(Base):
4848
)
4949
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
5050
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
51+
scorecard: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
52+
xp_earned: Mapped[int | None] = mapped_column(Integer, nullable=True)
5153

5254

5355
class SessionEvent(Base):

apps/api/src/helprs/modules/container/router.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"""Container session API routes."""
22

33
import json
4+
from typing import TYPE_CHECKING
45
from uuid import UUID
56

67
import structlog
78
from fastapi import APIRouter, Depends, Request
89
from fastapi.responses import StreamingResponse
910
from sqlalchemy import select
1011

12+
if TYPE_CHECKING:
13+
from sqlalchemy.ext.asyncio import AsyncSession
14+
1115
from helprs.core.config import get_settings
1216
from helprs.core.database import get_db_context
1317
from helprs.core.dependencies import DbSession, GetSettings, get_current_user
@@ -19,6 +23,7 @@
1923
from helprs.modules.container.schemas import (
2024
ContainerSessionResponse,
2125
CreateSessionRequest,
26+
ScorecardResponse,
2227
SendMessageRequest,
2328
SendMessageResponse,
2429
SessionEventResponse,
@@ -181,6 +186,7 @@ async def _event_stream():
181186
if completed.status == ContainerStatus.FAILED:
182187
msg = "Session failed."
183188
elif completed.status == ContainerStatus.COMPLETED:
189+
await _persist_scorecard(db_ctx, completed)
184190
await _post_results_comment(session_id, cs)
185191
except Exception:
186192
pass # Best effort; cleanup task handles stragglers
@@ -224,6 +230,25 @@ async def get_session_events_endpoint(
224230
)
225231

226232

233+
@router.get("/sessions/{session_id}/scorecard", response_model=ScorecardResponse)
234+
@limiter.limit("30/minute")
235+
async def get_session_scorecard(
236+
session_id: UUID,
237+
request: Request,
238+
db: DbSession,
239+
settings: GetSettings,
240+
user=Depends(get_current_user), # noqa: B008
241+
):
242+
"""Get the parsed scorecard for a completed session."""
243+
cs = await get_session_or_404(db, session_id)
244+
await verify_session_access(user, cs, db, settings)
245+
return ScorecardResponse(
246+
session_id=session_id,
247+
scorecard=cs.scorecard,
248+
xp_earned=cs.xp_earned,
249+
)
250+
251+
227252
@router.post("/sessions/{session_id}/message", response_model=SendMessageResponse)
228253
@limiter.limit("30/minute")
229254
async def send_session_message(
@@ -281,6 +306,33 @@ async def stop_container_session(
281306
)
282307

283308

309+
async def _persist_scorecard(db: "AsyncSession", cs: ContainerSession) -> None: # noqa: UP037
310+
"""Extract and persist the helprs-scorecard from session events (best-effort)."""
311+
from helprs.modules.container.scorecard import extract_scorecard
312+
313+
events = await get_session_events(db, cs.id)
314+
# Walk events in reverse to find the last assistant text block
315+
for event in reversed(events):
316+
data = event.data
317+
if data.get("type") == "assistant" and isinstance(data.get("message"), dict):
318+
for block in reversed(data["message"].get("content", [])):
319+
if block.get("type") == "text":
320+
scorecard = extract_scorecard(block.get("text", ""))
321+
if scorecard:
322+
cs.scorecard = scorecard
323+
await db.flush()
324+
return
325+
# Also check result events which carry the final text
326+
for event in reversed(events):
327+
data = event.data
328+
if data.get("type") == "result" and isinstance(data.get("result"), str):
329+
scorecard = extract_scorecard(data["result"])
330+
if scorecard:
331+
cs.scorecard = scorecard
332+
await db.flush()
333+
return
334+
335+
284336
async def _post_results_comment(session_id: UUID, cs: ContainerSession) -> None:
285337
"""Post challenge-me results to the PR as a GitHub comment (best-effort).
286338

apps/api/src/helprs/modules/container/schemas.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class ContainerSessionResponse(BaseModel):
5454
status: str
5555
started_at: datetime | None
5656
completed_at: datetime | None
57+
scorecard: dict | None = None
58+
xp_earned: int | None = None
5759
created_at: datetime
5860
updated_at: datetime
5961

@@ -103,3 +105,11 @@ class SessionEventsListResponse(BaseModel):
103105
session_id: uuid.UUID
104106
events: list[SessionEventResponse]
105107
total: int
108+
109+
110+
class ScorecardResponse(BaseModel):
111+
"""Parsed scorecard for a completed session."""
112+
113+
session_id: uuid.UUID
114+
scorecard: dict | None
115+
xp_earned: int | None
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Scorecard extraction from Claude session output.
2+
3+
Skills emit a structured JSON scorecard wrapped in a fenced code block
4+
tagged `helprs-scorecard`. This module parses it from the last assistant
5+
message text.
6+
"""
7+
8+
import json
9+
import re
10+
11+
SCORECARD_PATTERN = re.compile(
12+
r"```helprs-scorecard\s*\n(.*?)\n```",
13+
re.DOTALL,
14+
)
15+
16+
REQUIRED_FIELDS = {"skill", "version", "dimensions", "summary"}
17+
18+
19+
def extract_scorecard(text: str) -> dict | None:
20+
"""Extract the helprs-scorecard JSON block from text.
21+
22+
Returns the parsed dict if found and valid, None otherwise.
23+
"""
24+
match = SCORECARD_PATTERN.search(text)
25+
if not match:
26+
return None
27+
28+
try:
29+
data = json.loads(match.group(1))
30+
except (json.JSONDecodeError, ValueError):
31+
return None
32+
33+
if not isinstance(data, dict):
34+
return None
35+
36+
if not REQUIRED_FIELDS.issubset(data.keys()):
37+
return None
38+
39+
dims = data.get("dimensions")
40+
if not isinstance(dims, dict) or len(dims) != 3:
41+
return None
42+
43+
for value in dims.values():
44+
if not isinstance(value, (int, float)) or value < 0 or value > 10:
45+
return None
46+
47+
return data
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Tests for scorecard extraction from Claude session output."""
2+
3+
from helprs.modules.container.scorecard import extract_scorecard
4+
5+
6+
VALID_SCORECARD = """\
7+
Some assistant text before the scorecard.
8+
9+
```helprs-scorecard
10+
{
11+
"skill": "challenge-me",
12+
"version": 1,
13+
"questions_asked": 3,
14+
"questions_answered": 3,
15+
"dimensions": {
16+
"depth": 8,
17+
"clarity": 7,
18+
"rigor": 6
19+
},
20+
"summary": "Strong understanding of failure modes.",
21+
"highlights": ["Good instinct on idempotency"]
22+
}
23+
```
24+
25+
Some text after.
26+
"""
27+
28+
29+
def test_extract_valid_scorecard():
30+
result = extract_scorecard(VALID_SCORECARD)
31+
assert result is not None
32+
assert result["skill"] == "challenge-me"
33+
assert result["version"] == 1
34+
assert result["dimensions"] == {"depth": 8, "clarity": 7, "rigor": 6}
35+
assert result["summary"] == "Strong understanding of failure modes."
36+
assert result["highlights"] == ["Good instinct on idempotency"]
37+
38+
39+
def test_extract_returns_none_when_no_block():
40+
assert extract_scorecard("Just some regular text without a scorecard") is None
41+
42+
43+
def test_extract_returns_none_for_invalid_json():
44+
text = "```helprs-scorecard\n{invalid json}\n```"
45+
assert extract_scorecard(text) is None
46+
47+
48+
def test_extract_returns_none_for_missing_required_fields():
49+
text = '```helprs-scorecard\n{"skill": "challenge-me"}\n```'
50+
assert extract_scorecard(text) is None
51+
52+
53+
def test_extract_returns_none_for_wrong_dimension_count():
54+
text = """```helprs-scorecard
55+
{
56+
"skill": "challenge-me",
57+
"version": 1,
58+
"dimensions": {"depth": 8, "clarity": 7},
59+
"summary": "Missing one dimension"
60+
}
61+
```"""
62+
assert extract_scorecard(text) is None
63+
64+
65+
def test_extract_returns_none_for_out_of_range_scores():
66+
text = """```helprs-scorecard
67+
{
68+
"skill": "challenge-me",
69+
"version": 1,
70+
"dimensions": {"depth": 11, "clarity": 7, "rigor": 6},
71+
"summary": "Score out of range"
72+
}
73+
```"""
74+
assert extract_scorecard(text) is None
75+
76+
77+
def test_extract_returns_none_for_negative_scores():
78+
text = """```helprs-scorecard
79+
{
80+
"skill": "challenge-me",
81+
"version": 1,
82+
"dimensions": {"depth": -1, "clarity": 7, "rigor": 6},
83+
"summary": "Negative score"
84+
}
85+
```"""
86+
assert extract_scorecard(text) is None
87+
88+
89+
def test_extract_handles_float_scores():
90+
text = """```helprs-scorecard
91+
{
92+
"skill": "eli5",
93+
"version": 1,
94+
"dimensions": {"accuracy": 7.5, "simplicity": 8.0, "completeness": 6.5},
95+
"summary": "Good explanation"
96+
}
97+
```"""
98+
result = extract_scorecard(text)
99+
assert result is not None
100+
assert result["dimensions"]["accuracy"] == 7.5
101+
102+
103+
def test_extract_handles_different_skill_dimensions():
104+
text = """```helprs-scorecard
105+
{
106+
"skill": "pair-debug",
107+
"version": 1,
108+
"dimensions": {"detection": 9, "methodology": 7, "speed": 8},
109+
"summary": "Found the bug quickly"
110+
}
111+
```"""
112+
result = extract_scorecard(text)
113+
assert result is not None
114+
assert result["skill"] == "pair-debug"
115+
assert set(result["dimensions"].keys()) == {"detection", "methodology", "speed"}

0 commit comments

Comments
 (0)