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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ What this means for current work:

See `DESIGN.md` §1 (audience), §5 (Phase 4 / 4.5), §10 (distribution strategy).

## Current state (2026-05-11)
## Current state (2026-05-26)

- **Phase 0 scaffold:** complete.
- **Phase 1 auth:** complete. Silent session reuse via `~/.openkp/session.json` + httpx probe to `/mychartcn/keepalive.asp`. Interactive first-run Chromium, silent after. See ADR-005 and `docs/recon/session-2.md`.
Expand All @@ -32,6 +32,8 @@ See `DESIGN.md` §1 (audience), §5 (Phase 4 / 4.5), §10 (distribution strategy
- `emergency_contacts` (closes Phase 2) ✅ shipped + live-verified. Returns the full relationship roster — emergency contacts, DPOAHC healthcare agents, conservators — from a single Epic/MyChart endpoint. See `docs/research/endpoints/emergency_contacts.md`.
- `list_appointments` + `list_past_visits` ✅ shipped + live-verified 2026-05-04. Upcoming/in-progress visits (single-call, no pagination) and past visits (paginated walker with `max_pages`, `page_size`, `until_iso` bounds). Both back on the legacy `/mychartcn/Visits/VisitsList/<Load*>` family. **Live-verified twice: "when's my next appointment" returned next visit cleanly; "how many appointments in 2025, split virtual vs in-person" walked past visits and answered correctly (9 clinical encounters: 6 in-person + 3 virtual).** Filter HAR yielded the `numVisitsToRetrieve` discovery (default page=10 in front end, but Kaiser honors up to 78 — OpenKP defaults to 50, 5x fewer round trips for multi-year history). Filter-by-provider would be a future extension via `LoadFilterOptions` (see `appointments.md` "Filter index"). Session journal in sidecar.
- `read_visit_notes` + `download_visit_avs_pdf` ✅ shipped + live-verified 2026-05-04. Clinical notes (provider chart notes, progress notes, op notes) plus the rendered After Visit Summary, for one past visit. Four-step server-side chain (`GetVisitDetailsPast` → `GetVisitNotes` → per-note `ValidateVisitNote` + `LoadReportContent(contextINI=HNO)` → `LoadReportContent(reportMnemonic=AMB_AVS)`) collapsed into one tool. **Two-CSRF gotcha:** Kaiser scopes anti-forgery tokens by referer; ValidateVisitNote uses `/visits/note?csn=...` referer while everything else uses `/visits/past-details?csn=...`. AVS PDF download follows the labs-PDF pattern (GetDocumentDetails → DownloadOrStream). HTML-stripped to plain text on `content_text`, raw HTML preserved on `content_html`. See `docs/research/endpoints/visit_notes.md`. Session journal in sidecar.
- `list_care_team` ✅ shipped + live-verified 2026-05-26. The home-page "Care Team and Recent Providers" roster — PCP, specialists, recently-seen clinicians — each with specialty, relationship label, and per-provider capability flags. Strict superset of `get_profile`'s single PCP field. Back on the legacy `/mychartcn/Clinical/CareTeam/Load` + `LoadExternal` family (one CSRF token covers both POSTs; external providers are best-effort). Built from an existing complete-body capture, no fresh HAR needed. **Gotcha documented:** `can_message` reflects only the care-team panel's inline button, NOT reachability — messaging still runs through `list_message_recipients` + `send_message`. See `docs/research/endpoints/care_team.md`. Session journal in sidecar (session-21).
- `list_implants` ✅ shipped + live-verified 2026-05-26. Implanted/explanted devices (pacemakers, ICDs, leads, IOLs, ortho hardware) with manufacturer, model, serial, UDI/SDI, body area, laterality, status, and implant/explant procedure (date + derived `date_iso` + provider). Single CSRF-gated POST to `/mychartcn/api/implants/GetImplants`, no pagination. `implantGroupList` is a body-area ordering index (`"zzz"` = Epic's sort-unknown-last sentinel); detail lives in `implantList`. **Live finding:** the newest device can appear twice (curated "Cardiac Implant" record + raw device-feed "Pacemaker" record, same serial) — OpenKP returns both faithfully, callers dedupe on `(serial, date_iso)`. `isoDate` is a display-string misnomer (same trap as the AVS date). See `docs/research/endpoints/implants.md`. Session journal in sidecar (session-21).
- **Phase 3 write tools:** underway.
- `request_refill(medication_id, confirm=False)` ✅ shipped 2026-04-25 (mail-only v1). Two-call confirm pattern, audit log + dry-run scaffolding. **Preview path live-verified, commit path pending next real refill cycle.** See `docs/recon/session-11.md`.
- `track_refill_order(order_number)` ✅ shipped + live-verified 2026-04-27 (read sibling to request_refill). Single GET against `/orderDetails`. Surfaces order status (INPROGRESS / SHIPPED / DELIVERED), per-Rx detail, shipping address, payment last-4 / type / expiry, and a derived `tracking_ids` list. **Both INPROGRESS (HAR) and SHIPPED (live, 2026-04-27) verified against real Kaiser data.** Confirmed: `copay` on rxList entries populates post-adjudication (null on INPROGRESS, real $ once shipped), and `SHIPPED` is a real intermediate state where `digitalStatus="Complete"` even though `trackingId` is still empty (carrier handoff lags by hours/days). DELIVERED transition still unverified. See `docs/recon/session-13.md`.
Expand All @@ -40,7 +42,7 @@ See `DESIGN.md` §1 (audience), §5 (Phase 4 / 4.5), §10 (distribution strategy
- `download_message_attachment` ✅ shipped + live-verified 2026-04-25 (session 12). Two-step chain (`GetDocumentDetailsLegacy` → binary GET). Saves to `~/.openkp/downloads/`. Genetic panels and other clinically important documents arrive as message attachments — Kaiser doesn't surface them in test-results.
- `list_messages(deep_search=True, max_pages=30)` ✅ shipped + live-verified 2026-04-25 (session 12). Walks pagination via `localSummary.oldestSearchedInstantISO` because Kaiser's `searchQuery` is page-scoped, not index-scoped (default search misses anything older than the most recent ~50 threads). Use this when looking for archival messages. See `docs/research/endpoints/messages.md` "Search" section and `docs/recon/session-12.md`.

**Tests:** 527 passing. Run with `.venv/bin/pytest -q` from `openkp/`.
**Tests:** 567 passing. Run with `.venv/bin/pytest -q` from `openkp/`.

**CI:** GitHub Actions runs ruff + mypy + pytest on push/PR (Python 3.11/3.12/3.13). See `.github/workflows/ci.yml`. Status badge in root README.

Expand All @@ -61,6 +63,8 @@ Public release is done. Open code work is below.
- **OOC awareness:** the recipient catalog carries `oocDateISO` and `oocContextString` for providers who are out of office. Surface those as fields on `MessageRecipient` so the preview can flag "your provider is out of office until X" before the user commits.
- **`body_preview` rename or cap:** today's field name suggests truncation but the implementation only truncates above 200 chars. Either rename to `body` (full echo always) or always cap with `...` suffix when longer.

3. **`list_access_log`** — who/what accessed your record, incl. third-party apps and connected health services pulling specific data classes with timestamps. Strongly on-mission for the patient-owned-data framing. **Already has complete bodies** in `problems-allergies-documents-and-more.har` (`GetPortalAccessLogEntries` + `GetThirdPartyAccessLogEntries`, both legacy `/mychartcn/api/access-logs/`), so no fresh capture needed — same lucky situation as care_team/implants. The catch vs those two: it's paginated via a `startingLine` cursor and result sets get large (a connected app can log hundreds of "Test Result Details" reads), so it needs a bounded walker like `list_past_visits`. The portal-self log is boring (all "you accessed your own record"); the value is the third-party log.

**Loose ends (optional, not blocking):**
- ~~**`read_visit_notes` `iso` field is inconsistent.**~~ **Fixed 2026-05-10 (session 19).** AVS branch now parses the encounter-date display string ("Dec 04, 2025") to date-only ISO ("2025-12-04") via `_display_date_to_iso`. Clinical notes still carry full timestamp from `noteList[i].iso`. Field doc updated to spell out the two precision levels. Test pinned: `tests/test_visit_notes.py` asserts `avs.iso == "2025-01-01"` for the fixture.
- **Live-verify the `is_telemedicine` heuristic on `list_appointments` / `list_past_visits`.** Recon had zero virtual visits, so the heuristic (Telemedicine OR EVisit OR CanShowTelemedicine) is inferential. Cowork-Claude bypassed it by reading `visit_type` directly ("Telephone", "Video Visit"), but next time Hugo's calendar has a video or phone visit, peek at the dump to see whether the heuristic actually fires.
Expand Down
142 changes: 142 additions & 0 deletions docs/research/endpoints/care_team.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Care team endpoint

Source HAR: `docs/research/captures/kp-care-team-1.har`, 2026-04-23 (full
response bodies preserved). Implemented as `list_care_team` 2026-05-26.

## What this is

The "Care Team and Recent Providers" panel on the MyChart home page (right
column): the patient's primary care provider, specialists, and recently-seen
clinicians. Page route: `/mychartcn/clinical/careteam`.

This is a strict superset of what `get_profile` exposes. `get_profile` returns
only the PCP. This surface returns the whole care relationship roster plus
per-provider capability flags (can you message them, can you self-schedule).

Two endpoints back it, both on the legacy `/mychartcn/Clinical/CareTeam/Load*`
family — the same `/mychartcn/Clinical/<topic>/Load*` shape as problems,
allergies, and appointments:

| Feature | Endpoint | Status |
| --- | --- | --- |
| Internal KP providers | `POST /mychartcn/Clinical/CareTeam/Load` | ✅ Mapped + shipped. Real bodies. |
| External (non-KP) providers | `POST /mychartcn/Clinical/CareTeam/LoadExternal` | ✅ Mapped + shipped. Returned empty list in recon (no external providers in the captured data). Entry shape assumed identical to internal. |

## Auth / anti-forgery

Standard `/mychartcn/` CSRF contract (see `messages.md` / `problems.md`). Fetch
one `__RequestVerificationToken` and reuse it for **both** POSTs — in the HAR,
`Load` and `LoadExternal` carried the byte-identical token. Referer for both is
`https://healthy.kaiserpermanente.org/mychartcn/clinical/careteam`.

Both are GET-shaped POSTs: **no request body**, everything is in query params.

**Query params** (`Load`):

```
hfrId= (empty)
sources= (empty)
actions= (empty)
isPrimaryStandalone=true
ComponentNumber=2
noCache=<random float>
```

`LoadExternal` is identical minus `isPrimaryStandalone`.

## Response shape

```json
{
"ProvidersList": [
{
"ID": "WP-24...", // opaque Epic handle
"Name": "PAT EXAMPLE MD", // fabricated for this doc
"Photo": "https://www.permanente.net/pmdb/photosync/<id>_photoweb.jpg",
"NationalProviderID": "WP-24...", // opaque handle, NOT a real NPI
"WebPageUrl": "https://mydoctor.kaiserpermanente.org/ncal/doctor/<slug>",
"InfoBlurbUrl": "https://healthy.kaiserpermanente.org/hmdo/...",
"AboutMeBlurb": [],
"CanViewProviderDetails": true,
"CanDirectSchedule": false,
"CanRequestAppointment": false,
"CanMessage": false,
"CommCenterMessageUrl": "",
"CanRequestCustomAppt": false,
"HasNoProviderRecord": false,
"IsNewSchedulingEnabled": true,
"Specialty": "Family Practice",
"Relation": "Primary Care Provider", // "Cardiologist", etc.
"SchedulableVisitTypes": null,
"DepartmentID": "WP-24...", // opaque Epic handle
"Organizations": null,
"IsExternal": false,
"CareTeamStatus": 0, // raw int enum; 0 for all observed
"CanHideProvider": true
}
],
"DescriptiveTitle": "Care Team and Recent Providers",
"TabColorClass": "color1",
"IsCustomApptReqEnabled": false,
"CustomRequestAppointmentLink": "showform&formname=ApptReqCntr"
}
```

(Provider names and all `WP-24...` IDs above are fabricated/elided — the real
HAR contains the member's actual care relationships, which are PHI-adjacent.)

## Field mapping (scraper → `CareTeamProvider`)

| Model field | Source key | Notes |
| --- | --- | --- |
| `id` | `ID` | Opaque Epic handle. Required — entry dropped if missing. |
| `name` | `Name` | Display name incl. credential suffix. |
| `specialty` | `Specialty` | e.g. "Family Practice", "Cardiology". |
| `relation` | `Relation` | e.g. "Primary Care Provider", "Cardiologist". Populated, unlike the messages recipient catalog's null role. |
| `department_id` | `DepartmentID` | Opaque handle; pairs with scheduling. |
| `is_external` | `IsExternal` | True for `LoadExternal` entries. |
| `can_message` | `CanMessage` | Panel's inline quick-message button only — NOT reachability. See note below. |
| `can_schedule` | `CanDirectSchedule` | This panel's button only. |
| `can_request_appointment` | `CanRequestAppointment` | This panel's button only. |
| `can_view_details` | `CanViewProviderDetails` | |
| `photo_url` | `Photo` | permanente.net headshot URL. |
| `provider_page_url` | `WebPageUrl` | Public mydoctor.kaiserpermanente.org bio. |
| `care_team_status` | `CareTeamStatus` | Raw int; enum meaning unknown, 0 for all observed. |

Fields intentionally dropped: `NationalProviderID` (an opaque handle, not a
true NPI — misleading to surface), `InfoBlurbUrl`, `AboutMeBlurb`,
`CommCenterMessageUrl`, `IsNewSchedulingEnabled`, `SchedulableVisitTypes`,
`Organizations`, `HasNoProviderRecord`, `CanRequestCustomAppt`, `CanHideProvider`.
Easy to add later if a tool needs them.

## Behavior notes

- **`can_message` is not reachability.** It mirrors the care-team panel's inline
quick-message button, which Kaiser can leave off even for providers you can
message just fine. Messaging actually runs through a different surface
(`list_message_recipients` + `send_message`, the "Message your care team"
compose flow). A provider with `can_message=False` here may still be a valid
recipient there. Observed live 2026-05-26: both providers in the captured
data came back `can_message=False` on this panel, which an LLM read as "outreach must go
through their department" — misleading, since they're messageable via
`send_message`. The same panel-button-only caveat applies to `can_schedule`
and `can_request_appointment`. The tool docstring and model comments now spell
this out so callers don't treat these flags as gates.
- `LoadExternal` is best-effort in the scraper: if it errors, we still return
the internal roster. External providers are a bonus, not the primary data.
- The external entry shape is **assumed identical** to internal — recon had an
empty external list, so it is untested against real external data. The parser
is defensive (never raises) so a shape surprise degrades to partial/empty.
- No pagination. Kaiser returns the full roster in one call each.

## Open questions / future work

- **Live-verify external providers.** Need a member who has a non-KP provider
on file to confirm the `LoadExternal` entry shape matches internal.
- **`CareTeamStatus` enum.** Only `0` observed. Could distinguish
active/inactive/recent — capture a roster with a dropped provider to learn.
- **`relation` → recipient linkage.** The care team `id` is a different opaque
handle than the `send_message` recipient catalog id. If we ever want
"message my cardiologist" to chain `list_care_team` → `send_message`, we
need to confirm whether the two ID spaces are reconcilable or whether
messaging must always go through `list_message_recipients`.
Loading
Loading