fix(text): repair extraction glyph artifacts app-wide and route all document-derived text through formatters - #121
Conversation
…ocument-derived text through formatters - Add normalizeExtractedGlyphs (ligatures, soft hyphens, zero-width/control chars; deliberately no line-break de-hyphenation — fusing would corrupt clinical compounds like low-dose) wired into the base compactWhitespace/ readableWhitespace cleaners so every formatter inherits it - Fix bypass surfaces: verbatim quote body + clipboard, citation labels (formatCitationLabel/formatCompactCitationLabel now glyph-clean titles), source titles, visual-evidence labels, table snippets, image captions/alt, evidence-map details, AccessibleTable cells/captions - Drop unknown-filler segments from provenance joins while always keeping clinical governance warnings (source-metadata.ts, ui-primitives.tsx) - Normalize glyphs at ingestion (buildChunks) with source-span offsets kept consistent via a single normalized page text - Add scripts/backfill-text-normalization.ts: in-place, no-re-embed, dry-run by default, revertible JSON backup, project-guarded - Centralize static UI copy in src/lib/ui-copy.ts (empty states, errors, starter prompts) for the dashboard and document manager - Add tests/rendered-text-formatting.test.ts guard against new raw interpolations; document conventions in docs/process-hardening.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erg-9a53a9 # Conflicts: # src/components/ClinicalDashboard.tsx # src/components/clinical-dashboard/answer-status.tsx
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR standardizes handling of document-derived text across the application by introducing a shared glyph-normalization primitive and routing previously raw render surfaces through existing formatter helpers, while also centralizing static UI chrome copy and adding regression tests + an optional stored-text backfill script.
Changes:
- Adds
normalizeExtractedGlyphsand wires it into base whitespace cleaners so downstream formatters inherit PDF-extraction glyph repair. - Updates multiple UI surfaces (quotes, titles, citations, tables, image captions, provenance) to avoid raw interpolation of extracted text.
- Introduces a dry-run-by-default backfill script plus new tests and documentation to prevent regressions.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/source-text-sanitizer.test.ts | Adds unit coverage for glyph normalization and the new verbatim-quote cleaner. |
| tests/source-metadata.test.ts | Updates provenance-summary expectations (drop unknown filler segments; keep governance warnings). |
| tests/rendered-text-formatting.test.ts | Adds static “no raw interpolation” guards for known document-derived render surfaces. |
| tests/citations.test.ts | Adds coverage for glyph repair + Synthetic prefix stripping in citation labels. |
| src/lib/ui-copy.ts | Introduces a central module for static UI chrome copy (empty states, errors, labels). |
| src/lib/source-text-sanitizer.ts | Adds normalizeExtractedGlyphs, integrates into cleaners, and introduces sourceTextForVerbatimQuote. |
| src/lib/source-metadata.ts | Drops “unknown” filler segments from visible provenance summaries while keeping governance warnings. |
| src/lib/citations.ts | Cleans citation titles centrally (glyph repair + Synthetic prefix removal) before building labels. |
| src/lib/chunking.ts | Normalizes extracted glyphs during chunk building so stored chunk text and offsets stay consistent. |
| src/components/ui-primitives.tsx | Adjusts provenance UI to drop unknown filler segments while retaining governance signals. |
| src/components/DocumentViewer.tsx | Routes image/table captions/headings through compact display formatting and improves alt text. |
| src/components/DocumentManagementActions.tsx | Documents intentional use of raw stored document title for destructive confirmation UX. |
| src/components/ClinicalDashboard.tsx | Routes quotes/titles/snippets through formatters; centralizes empty/error copy usage. |
| src/components/clinical-dashboard/master-search-header.tsx | Uses cleanDisplayTitle for scoped-document display strings and tooltips. |
| src/components/clinical-dashboard/DocumentManagerPanel.tsx | Centralizes empty/error strings and cleans displayed document titles. |
| src/components/clinical-dashboard/display-text.ts | Adds cleanDisplayTitle using glyph normalization; reuses it for source titles. |
| src/components/clinical-dashboard/answer-status.tsx | Moves empty-state and button copy to ui-copy.ts. |
| src/components/AccessibleTable.tsx | Normalizes extracted glyphs in table cell text cleaning. |
| scripts/backfill-text-normalization.ts | Adds an opt-in, safety-guarded stored-text backfill script (no re-embed). |
| package.json | Adds backfill:text-normalization script entry. |
| docs/process-hardening.md | Documents formatter/copy conventions and the new backfill + regression-test guard. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 143ec9f220
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…arify verbatim-quote whitespace contract Addresses Copilot review on PR #121: '--limit foo' / '--limit 0' previously fell through the falsy truthiness check and silently meant unlimited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…omitted-image markers, boilerplate keys, backfill hardening - Convert VT/FF/NEL controls to newlines instead of deleting (deleting fused words like dose\fmonitoring) - Strip [[IMAGE_DATA_OMITTED]] markers from verbatim quotes and all display paths - Build repeated-boilerplate keys from the same normalized page text that removePageNoise compares against - Backfill: make the revertible backup mandatory (no --no-backup escape hatch) and include retrieval_synopsis in scan/patch/backup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/components/DocumentManagementActions.tsx:203
- The delete-confirmation gate compares against document.title byte-for-byte, but titles can contain invisible extraction artifacts (soft hyphens / zero-width / control chars) that users cannot reliably see or type. That can make deletion impossible even when the user types what appears to be the exact title, and it also reintroduces glyph artifacts into a rendered surface. Consider displaying a cleaned title and comparing confirmation using normalizeExtractedGlyphs() on both sides (or allowing either exact-match or normalized-match).
{/* Deliberately the RAW stored title: the confirmation input is compared
against document.title verbatim, so the user must see the exact string. */}
<p className={cn("break-words text-xs font-semibold", textMuted)}>{document.title}</p>
<div className="flex flex-wrap justify-end gap-2">
<button type="button" className={floatingControl} onClick={closeDialog} disabled={pending}>
Cancel
</button>
<button
type="submit"
className={cn(primaryControl, "bg-[color:var(--danger)] hover:bg-[color:var(--danger)]")}
disabled={pending || deleteConfirmation !== document.title}
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
…dry-run memory flat Post-merge Copilot follow-ups on PR #121: - sourceTextForIndexedPage now starts from normalizeExtractedGlyphs (spaces/ tabs preserved, so fixed-width table parsing is unaffected) instead of only normalizing CRs — the indexed source text panel no longer leaks ligatures - Backfill dry-run tracks a counter + capped samples only; the full changed-row list (needed for the mandatory backup) is materialized only when writing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Database/src/lib/source-text-sanitizer.ts
Lines 403 to 405 in 9e2c89c
When the selected page is rendered through sourceTextForIndexedPage, this path still starts from the raw stored page text and never calls normalizeExtractedGlyphs, unlike the other display helpers. Existing document_pages.text values containing ligatures, soft hyphens, or zero-width/control characters will therefore still show those extraction artifacts in the DocumentViewer page text even though this change cleans chunks/citations; normalize the text at the start of this helper while preserving its fixed-width spacing behavior.
Database/src/components/DocumentViewer.tsx
Lines 425 to 426 in 9e2c89c
When an image row has tableTextSnippet but no structured table (or the snippet is passed as markdown with clinicalOnly false), the DocumentViewer still renders the extracted table text raw here. Snippets containing fi, soft hyphens, zero-width controls, or internal image/table markers will leak on the document page despite the new cleaned caption/title path, so route tableTextSnippet through the same compact/display formatter before passing or rendering it.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| detail: | ||
| sourceTextForCompactDisplay(row.quote || row.source.snippet || row.source.reason || "") || | ||
| cleanDisplayTitle(row.source.title), |
There was a problem hiding this comment.
Sanitize evidence-map passage samples
This only cleans the row detail, but EvidenceMapTable renders the separate bestLinkedPassage as the “Passage sample”; for render-model evidence rows that field is still assigned from the raw quote/snippet/reason just below. When a cited quote or snippet contains ligatures, soft hyphens, or internal image markers, switching to the evidence-map view still exposes those artifacts, so apply the same compact/verbatim formatter to the passage sample field too.
Useful? React with 👍 / 👎.
| for (;;) { | ||
| let query = supabase | ||
| .from("document_chunks") | ||
| .select("id,document_id,content,section_heading,retrieval_synopsis") |
There was a problem hiding this comment.
Normalize section paths in the backfill
When existing chunks have glyph artifacts in extracted headings, this backfill normalizes section_heading but leaves document_chunks.section_path untouched because it is not selected or patched. Search/ranking paths still combine section_path into section text/haystacks (for example src/lib/clinical-search.ts joins result.section_path), so a confirmed write run can leave the old ligatures/soft hyphens leaking into retrieval context and facets even after the heading itself was cleaned.
Useful? React with 👍 / 👎.
…laim Codex review is correct and this retracts the previous commit's central claim. Reading `form.document-viewer-composer[data-scroll-hidden]` as a proxy for the header's `scrollHidden` was wrong: they are separate state machines. The header is driven by the shell's `chromeScrollHide` (global-search-shell.tsx:332), fed only by `useDocumentScrollHideReporter` (line 345) and passed in at line 876, while DocumentViewer runs its own two `useHideOnScroll` instances (use-document-viewer-chrome-scroll.ts:20-30). Composer-hidden therefore proves DocumentViewer's reporter fired and says nothing about the header's, so it never separated a pin from a reporter-never-fired — the exact distinction the helper claimed to make. `data-scroll-signal` on the collapse wrapper now publishes the header's raw `scrollHidden` before the pin is applied, and `expectChromeHidden` reports it alongside DocumentViewer's so a divergence between the two feeds is visible instead of collapsed into one verdict. Nothing styles the attribute; no CSS or code reads it (verified by grep), so behaviour is unchanged. Ledger #127 is corrected rather than patched over: the "traces prove sharedChromePinned is stuck" claim is explicitly withdrawn, what the traces do establish is separated from what they do not, and the new leading hypothesis is recorded as untested — the shell's feed is document-only, so where `#main-content` owns scrolling `window.scrollY` never moves and the shell reporter cannot see the gesture, which would explain the standalone-PWA variant directly. It also now says not to infer the header's scroll state from any page-owned composer. Verified: verify:cheap exit 0 — Test Files 434 passed (434), Tests 4562 passed | 4 skipped (4566); typecheck and lint clean; prettier clean; the full phone-scroll spec 56 passed (4.4m) against an isolated production build. That build used the container's Chromium 1194, not the bundled 1234 CI installs (#121), so it proves the attribute broke nothing and nothing more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrPbbfU9yWuEjEVypCr4ZQ
…it asks for A real conflict this time, not staleness: main's PR #1427 rewrote the same helpers this branch touches, and it very likely found the actual cause of #127. `addPhoneScrollRunway` slept 50ms and merely hoped the appended 1600px runway had reached layout; `dragScrollBy` clamped silently at the end of the range while reporting nothing. Under CI load the drag therefore delivered less than it asked for and the chrome was right to stay visible. #1427 polls for the runway, returns the distance actually travelled, and `dragScrollUntilHidden` refuses to expect a hide until remaining runway and delivered travel both clear 160px. Main's helpers are taken whole. This branch keeps only what #1427's own comment says is still missing: "Separating THOSE two still needs the pin state exposed in the DOM; today only the composite `data-scroll-hidden` (`scrollHidden && !sharedChromePinned`) is observable, so both look identical." `data-scroll-signal` publishes the raw signal, and `expectChromeHidden` is cut down to answer only that question, now layered after `dragScrollUntilHidden` rather than duplicating its travel proof. #127 is rewritten again and withdraws a second wrong diagnosis of my own: a short/clamped drag was ruled out early using a maxOffset of 2753 read at a different trace moment than the failing drag, when the pre-runway reading in that same trace was 1153 — and a runway not fully landed puts the offset in the near-bottom band where computeScrollHideUpdate legitimately refuses. That is exactly what #1427 fixes. The row now points at #1427 as the likely fix, keeps the observability gap as the only open part, and says to close it if no recurrence appears on a post-#1427 head. Main also claimed #135 for an unrelated issue, so the union-driver finding renumbers to #140, marker 141. Verified: typecheck 0 errors, lint 0 problems, whole-tree prettier clean, check:outstanding-issues 138 rows / unique ids / next-id=141, and the merged phone-scroll spec 56 passed (4.1m) against an isolated production build — under Chromium 1194, not CI's bundled 1234 (#121). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrPbbfU9yWuEjEVypCr4ZQ
…1430) * test(phone-chrome): name which value holds the header open, not just that it did `data-scroll-hidden` on the collapse wrapper is `scrollHidden && !sharedChromePinned`, so a missing attribute has two very different causes the assertion cannot separate: the scroll state machine never fired, or it fired and a pin held the chrome open. The bare assertion reads as the first even when it is the second — which is how a stuck pin was misread as a flaky scroll gesture across two CI runs on 2026-07-30. `expectChromeHidden` keeps the same pass condition and adds a failure message. The discriminator is already in the DOM: DocumentViewer's page-owned composer hides on `composerScrollHidden`, which consults `scrollHidden` and not the pin, so composer-hidden plus header-visible proves the pin. Every term of `sharedChromePinned` also has a DOM tell — an `aria-expanded` trigger, a popover, or focus inside the portaled addon host — so when all read false the pin is a stale latch rather than a live surface, and the message says so. Also updates ledger #127 with what the traces establish: `scrollHidden` is TRUE and `sharedChromePinned` is stuck, reproducible on both completed full-suite runs and both variants, always at the reduced-motion hide that follows the section-sheet round-trip and never at the first hide. `main` only looks green because `Production UI` is skipped on its docs-only pushes; it has not run this test since 90b3e34, with zero `src/` changes since. Deliberately not the fix. Which term latched is proven; the mechanism is inferred, and it does not reproduce locally — every local run used the container's Chromium 1194 rather than the bundled 1234 CI installs (#121), so no local green is evidence here. This makes the next CI failure name its own cause instead of costing another trace download. Verified: typecheck clean, lint clean, prettier clean, check:outstanding-issues 125 rows / unique ids / next-id=128, and both affected tests still pass locally (2 passed, 12.6s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrPbbfU9yWuEjEVypCr4ZQ * fix(test): publish the header's own scroll signal; withdraw the pin claim Codex review is correct and this retracts the previous commit's central claim. Reading `form.document-viewer-composer[data-scroll-hidden]` as a proxy for the header's `scrollHidden` was wrong: they are separate state machines. The header is driven by the shell's `chromeScrollHide` (global-search-shell.tsx:332), fed only by `useDocumentScrollHideReporter` (line 345) and passed in at line 876, while DocumentViewer runs its own two `useHideOnScroll` instances (use-document-viewer-chrome-scroll.ts:20-30). Composer-hidden therefore proves DocumentViewer's reporter fired and says nothing about the header's, so it never separated a pin from a reporter-never-fired — the exact distinction the helper claimed to make. `data-scroll-signal` on the collapse wrapper now publishes the header's raw `scrollHidden` before the pin is applied, and `expectChromeHidden` reports it alongside DocumentViewer's so a divergence between the two feeds is visible instead of collapsed into one verdict. Nothing styles the attribute; no CSS or code reads it (verified by grep), so behaviour is unchanged. Ledger #127 is corrected rather than patched over: the "traces prove sharedChromePinned is stuck" claim is explicitly withdrawn, what the traces do establish is separated from what they do not, and the new leading hypothesis is recorded as untested — the shell's feed is document-only, so where `#main-content` owns scrolling `window.scrollY` never moves and the shell reporter cannot see the gesture, which would explain the standalone-PWA variant directly. It also now says not to infer the header's scroll state from any page-owned composer. Verified: verify:cheap exit 0 — Test Files 434 passed (434), Tests 4562 passed | 4 skipped (4566); typecheck and lint clean; prettier clean; the full phone-scroll spec 56 passed (4.4m) against an isolated production build. That build used the container's Chromium 1194, not the bundled 1234 CI installs (#121), so it proves the attribute broke nothing and nothing more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrPbbfU9yWuEjEVypCr4ZQ * Merge origin/main; rebuild the ledger the union driver doubled `PR mergeability` flagged this branch, but `git merge-tree` returned a clean tree — behind-but-clean staleness, not a content conflict. The merge itself then reported success while producing every open-items row twice (`#59 appears 2 times (lines 101, 166)` and so on for the whole table): `.gitattributes` sets `merge=union` on this file, which is git's built-in concatenate-both-sides driver with no dedupe, and the table is not append-only. Rebuilt from `origin/main` (now through #134) with only the one row this branch actually changed re-applied. Recorded as #135, since the driver turns a resolvable conflict into a guaranteed guard failure and makes `merge-tree` look clean. Also corrects #127's own framing. `Production UI` PASSED on run 30530393684, so the failure is intermittent at 2 of 3 completed runs, not reproducible as the previous row claimed — that was premature on two datapoints. The `data-scroll-signal` diagnostic therefore has not yet had a failure to report; it is still the thing that will name the cause when one comes. Verified: check:outstanding-issues 133 rows / 67 open / unique ids / next-id=136; check:branch-review-ledger 112 live + 1206 archived; whole-tree prettier clean; no conflict markers under docs/, tests/ or src/. Not re-run for this merge: verify:cheap and the phone-scroll spec — the code changes are unchanged from 5495f28, where both passed (434 test files / 4562 tests, and 56 passed), and this commit touches only the ledger plus main's own already-verified tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrPbbfU9yWuEjEVypCr4ZQ * Merge origin/main (fcd9041); rebuild the ledger rather than trust union Third staleness resolution on this branch in about forty minutes. `PR mergeability` again reported `mergeable_state: dirty` and "a real merge conflict", but `git merge-tree --write-tree` returned a clean tree, so this is behind-but-clean staleness, not a content conflict. The single overlapping path between this branch and main is `docs/outstanding-issues.md` — nothing else on this branch is contested, which is why the code files are byte-identical to 5495f28. The file is rebuilt from `origin/main` with this branch's two rows re-applied (#127 corrected, #135 added after #134, marker 136) instead of keeping the union driver's output, which concatenates both sides of every overlapping hunk without dedupe and doubled the whole table last time — that behaviour is what #135 records. Main's #127 still carried the withdrawn "sharedChromePinned is stuck" text and #135 was unclaimed, so neither graft overwrote anyone else's edit. Verified: check:outstanding-issues 133 rows / unique ids / next-id=136; check:branch-review-ledger 113 live + 1206 archived; whole-tree prettier clean. Not re-run: verify:cheap and the phone-scroll spec — `git diff 5495f28 -- src/ tests/` is empty, so the code carries that commit's evidence (434 test files / 4562 tests, and 56 passed) unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XrPbbfU9yWuEjEVypCr4ZQ * docs: record PR 1430 review --------- Co-authored-by: Claude <noreply@anthropic.com>
Codex review was right: #121 ("Container Playwright browser build lags the pinned client") already tracks this exact condition — client 1234 versus container 1194, every browser test failing at launch — so #145 created a second canonical action for one problem. The row was allocated without first searching the open table, which is the dedupe step the issues skill requires. #145 is removed and its distinct content folded into #121: the reproduction on main at c5c1a86, the fact that the condition has now been misread twice (the handoff's 13 launch failures, and #120 filed as a gate defect under it), the detection command to run before trusting a browser gate, and the stop rule against filing a gate defect from a run whose tests never launched. #121's own workaround, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD note and open Next decision are unchanged. The id marker rolls back 146 -> 145 because #145 was never used by a live row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K7sEKH35KZkWxvCnQcNN2
Sync main, then update two rows against evidence rather than adding new ones. #98: PR #1450 landed the counting proxy and answer-path budgets while this branch was open. Verified rather than assumed — the helper counts on execution not construction, tests/rag-round-trip-budget.test.ts pins two answer-path scenarios plus three counter self-tests, and it is registered in the offline contract fixture so it runs there. Ran it: Test Files 1 passed (1), Tests 5 passed (5). The row stays open with its Next narrowed to the two real gaps: /api/search has no budget, and eval-rag-offline/test-rag-offline were not wired. Also records the helper's own blind spot — it sees only traffic through the wrapped client. #130: already owns the unfiled pre-paint/cold-load guard, so its design goes there instead of a new row. Records what the guard must test (the pre-paint reserve seed, sampled before and after hydration rather than once after), why a zero-inset profile is required for it to be able to fail at all, and that it must be proven against the broken shape first. Also records the environment blocker: browser gates cannot launch here per #121, and the symlink bridge writes under /opt, which the sandbox refuses. No new ids allocated; both are updates to rows that already own the work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K7sEKH35KZkWxvCnQcNN2
…gn (#1455) * issues: close #122, capture the container Playwright pin mismatch Three related ledger items, each independently revertible. Close #122 (`ci/circleci: verify` fails on every branch). Its outcome allowed either "trustworthy signal again, or it stops reporting"; the second happened. `.circleci/config.yml` was deleted by 9779828 (PR #1412), and PR #1452's head reported 21 check runs with none named `ci/circleci: verify`, so the status no longer reports on new PRs. No operator log read is needed and the quota hypothesis is retired unproven. Capture #145: the remote container ships Chromium 1194 while the repo's Playwright pin wants 1234, so every browser test dies at launch and zero assertions run while the output reads like product breakage. This has cost time twice — the 2026-07-30 handoff records 13 launch failures read as a code defect, and #120 was filed on a gate reading taken under the same condition. The row gives the start-of-session check and keeps the existing "never run npx playwright install" stop rule. Fix a stale rule found while verifying #122: AGENTS.md cited `ci/circleci: verify` as a check that fails on unformatted files. It cannot report again, so the rule now names `Static PR checks` and records the CircleCI failures as history. The outstanding-issues diff is 4 insertions / 3 deletions ignoring whitespace; the rest is Prettier re-padding the archive table, because #122's original summary is wider than that column and was kept verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K7sEKH35KZkWxvCnQcNN2 * issues: fold the Playwright pin evidence into #121, drop duplicate #145 Codex review was right: #121 ("Container Playwright browser build lags the pinned client") already tracks this exact condition — client 1234 versus container 1194, every browser test failing at launch — so #145 created a second canonical action for one problem. The row was allocated without first searching the open table, which is the dedupe step the issues skill requires. #145 is removed and its distinct content folded into #121: the reproduction on main at c5c1a86, the fact that the condition has now been misread twice (the handoff's 13 launch failures, and #120 filed as a gate defect under it), the detection command to run before trusting a browser gate, and the stop rule against filing a gate defect from a run whose tests never launched. #121's own workaround, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD note and open Next decision are unchanged. The id marker rolls back 146 -> 145 because #145 was never used by a live row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K7sEKH35KZkWxvCnQcNN2 * issues: record #98 delivery and #130 pre-paint guard design Sync main, then update two rows against evidence rather than adding new ones. #98: PR #1450 landed the counting proxy and answer-path budgets while this branch was open. Verified rather than assumed — the helper counts on execution not construction, tests/rag-round-trip-budget.test.ts pins two answer-path scenarios plus three counter self-tests, and it is registered in the offline contract fixture so it runs there. Ran it: Test Files 1 passed (1), Tests 5 passed (5). The row stays open with its Next narrowed to the two real gaps: /api/search has no budget, and eval-rag-offline/test-rag-offline were not wired. Also records the helper's own blind spot — it sees only traffic through the wrapped client. #130: already owns the unfiled pre-paint/cold-load guard, so its design goes there instead of a new row. Records what the guard must test (the pre-paint reserve seed, sampled before and after hydration rather than once after), why a zero-inset profile is required for it to be able to fail at all, and that it must be proven against the broken shape first. Also records the environment blocker: browser gates cannot launch here per #121, and the symlink bridge writes under /opt, which the sandbox refuses. No new ids allocated; both are updates to rows that already own the work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K7sEKH35KZkWxvCnQcNN2 --------- Co-authored-by: Claude <noreply@anthropic.com>
Earlier today I recorded on #121 that a sandboxed remote session has only two options for the Chromium 1194-vs-1234 mismatch: get the /opt write permission, or decline to claim browser evidence. That was an over-generalisation from a single blocked mkdir, and it is wrong. PR #1432 landed a preflight whose own failure message names the route I had missed: PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, read by playwright.config.ts:11 and honoured by scripts/playwright-browser-preflight.mjs:101. It needs no filesystem write. Verified by launching rather than by reading the flag — the container's existing 1194 headless_shell drives fine under the repo's Playwright 1.62 client (version 141.0.7390.37, page rendered, boundingBox measured). #121 keeps the wrong sentence with the retraction beside it, because which claim was wrong and why is the part worth carrying forward. #130's recorded blocker for the pre-paint guard is marked LIFTED: that guard is buildable in a remote session after all. Also records #1432 itself, which no row referenced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K7sEKH35KZkWxvCnQcNN2
Both findings verified against the code before accepting, and both were right. 1. scripts/rag-offline-contract.mjs still described the suite as "the same guard for /api/search". That is the same overstatement Codex caught in the test and the ledger, and I had missed this third copy of it. The comment now says retrieval core and names what the suite does not observe: the route's auth, rate limiting, scope resolution, enrichment and telemetry write. 2. vi.doMock registrations survive vi.restoreAllMocks (which targets spies) and vi.resetModules (which clears the module cache, not the mock registry), so the Supabase/OpenAI fakes could outlive this file if isolation were relaxed. afterEach now calls vi.doUnmock for both. This is the established pattern here rather than a generic suggestion: five sibling suites already do it, and the comment cites tests/rag-variant-early-exit.test.ts:110. Also resolves the fourth docs/outstanding-issues.md conflict from main advancing, by rebuilding the #98/#121/#130 edits on main's table and diffing row-id sets against main to prove nothing was lost. Focused suites after the change, including the neighbouring mock-sensitive ones: Test Files 3 passed (3), Tests 9 passed (9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018K7sEKH35KZkWxvCnQcNN2
… retract a wrong #121 claim (#1464) Budget the search retrieval core: tests/search-round-trip-budget.test.ts pins Supabase round-trip counts for searchChunksWithTelemetry, registered in the offline contract. Scoped explicitly as retrieval-core, not an /api/search endpoint budget — the route's auth, rate limiting, scope resolution, enrichment and telemetry write are outside what it observes. One retrieval measures 11 round trips, with match_document_chunks_text_v2 and match_document_table_facts_text_v2 each firing three times; whether that is intended is left open on #98 rather than guessed at. Retract a wrong claim on #121: a sandboxed remote session can produce browser evidence via PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, verified by launching. The earlier "only two options" sentence is kept with the retraction beside it. #130's blocker is marked LIFTED as a result, so the pre-paint guard is buildable.
Summary
fi fl, soft hyphens, zero-width/control characters) no longer reach any rendered surface.normalizeExtractedGlyphsinsource-text-sanitizer.ts— wired into the basecompactWhitespace/readableWhitespacecleaners so all ~20 downstream formatters inherit the repair at once. Deliberately does not rejoin line-break hyphenation: a soft-wrap hyphen is indistinguishable from a real compound hyphen (low-dose,twice-daily), so fusing would corrupt clinical compounds (caught by adversarial review, with regression tests).sourceTextForVerbatimQuote— verbatim wording, artifacts repaired), citation labels (formatCitationLabel/formatCompactCitationLabelnow glyph-clean titles centrally, fixing mobile labels + ward-output/smart-rag/clinical-safety), document titles (cleanDisplayTitle), visual-evidence labels, table snippets, image captions/alt, evidence-map details, andAccessibleTablecells/captions.buildChunks) normalizes page text once, keeping stored chunk content and source-span offsets consistent so citation anchoring is unaffected.scripts/backfill-text-normalization.ts(npm run backfill:text-normalization): in-place stored-text cleanup, no re-index, no re-embed (vectors frozen → retrieval unchanged by construction), dry-run by default, requires--write --confirm, writes a revertible JSON backup, refuses to run against the wrong Supabase project. Not yet run against production.src/lib/ui-copy.ts; addstests/rendered-text-formatting.test.tsas a static guard against reintroducing raw interpolations; conventions documented indocs/process-hardening.md.main(mode homes, Clinical White restyle, relevance-first selection) with conflicts resolved so design changes win on structure/styling and formatter wiring is preserved (AnswerEmptyStatenow feedsModeHomeTemplatefromui-copy.ts).Verification
npm run verify:cheap(runtime check, eslint, typecheck, 787 unit tests) — pass on the merged branchnpm run verify:ui— not run locally (no env in this worktree); relying on the CI Chromium UI gatenpm run verify:release— not claimednpm run format:check— all files touched by this PR are clean; 9 remaining warnings are pre-existing drift frommain(rag.ts,rag-provider.ts, docs, unrelated tests), intentionally not reformatted herenpm run check:production-readiness— not run locally (no env in this worktree); no environment, Supabase config, or deployment behavior changes are includednpm run check:deployment-readiness— n/a, no deployment behavior changesClinical Governance Preflight
Clinical KB Database(sjrfecxgysukkwxsowpy) — the backfill script hard-fails on any other projectSynthetictitle prefix is stripped only for display labels, not from stored dataNotes
npm run check:supabase-project→npm run backfill:text-normalization(dry-run prints changed-chunk count + samples) →--write --confirm.docs/process-hardening.md: staleindex_quality_issues/extraction_qualitymetadata recompute; source-metadata governance debt expires 2026-07-31.🤖 Generated with Claude Code