Skip to content

fix(assembly): a document declares the language its pages are in, not English by default - #191

Open
claude[bot] wants to merge 1 commit into
mainfrom
iris-auto/issue-163
Open

fix(assembly): a document declares the language its pages are in, not English by default#191
claude[bot] wants to merge 1 commit into
mainfrom
iris-auto/issue-163

Conversation

@claude

@claude claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #163. Reported by @bbertucc.

wrapDocument hardcoded <html lang="en">, so a document assembled from Korean pages declared
itself English. A screen reader picks its voice from that attribute, so the hardcoded value is a
wrong statement rather than a missing one (WCAG 3.1.1, Language of Page) — and one nothing in the
pipeline could see: html-has-lang and html-lang-valid are both satisfied by a confident en.

The prompt half landed with #162 (a page wholly in another language puts lang on every top-level
element it emits, which lands inside <main>). This is the code half — the root, which the prompt
cannot reach and which only assembly can see the pages agreeing on.

  • documentLang (src/pipeline/assembly.ts) declares another language only where every
    top-level element of the assembled body carries the same valid BCP 47 lang. The boilerplate
    <title> then carries lang="en" of its own, so the shell's one English string stays labelled;
    it is omitted on an English document, where it would only repeat the root.
  • Three cases keep en, per the issue's design: the pages disagree (a multilingual document has no
    single primary language, and picking one is a worse statement than declaring the shell's), one of
    them is silent (no language is inferred for a page that reported none), or bare text sits at the
    top level. The value is only as good as the fragments — the root follows the content, never runs
    ahead of it.
  • The tag is validated (LANG_TAG) before it reaches the shell, doing two jobs: the value comes out
    of model-written markup into an attribute in a string template, so nothing that could carry a
    quote out of it may pass; and an invalid tag would fail html-lang-valid at the root, where
    the review loop (which only edits the body) could not fix it — in the fragment the same mistake
    fails valid-lang on an element the Copy Editor can rewrite, which is where it belongs. Being too
    strict costs only the fallback to en, i.e. today's behaviour.
  • No parse is added to the ordinary run: a body with no lang anywhere in it cannot have one on a
    top-level element, so the answer is returned without building a DOM. wrapDocument runs on the
    whole body once per review round, which is why that shortcut is there.
  • Logged as lang on the assembly event only when it moves off en, following the convention
    that a field with nothing to say is absent. README's "where v1 stops" list documents the rule.

No new dependencies (jsdom is already used by anchors.ts/flatten.ts/lint.ts). 111 non-test
lines changed, most of them comment.

Testing

  • npm run typecheck — clean.
  • npm test — 730 tests, 0 fail (722 on main; the 8 new ones are test/document-lang.test.ts).
  • ./test/e2e.shALL ENDPOINTS PASSED ✅.
  • If the demo changed, it still passes axe-core with 0 violations — public/demo.html is not
    touched by this PR
    , so no demo axe run was done and none is claimed.

test/document-lang.test.ts pins both directions: what is derived (agreeing pages, subtags,
case-insensitive agreement) and the four shapes that keep en, plus invalid and quote-carrying
tags, that the @page-failed/@unresolved markers appended after the body do not disturb the
derivation, that a derived tag still passes the axe gate the defect was invisible to, and the log
field on runAssembly. The stale comment in test/page-prompt.test.ts that said nothing derives
the shell's lang from the content now says what does.

Why this issue

Ranked 1 on the triage order — a barrier in the HTML Iris produces — and it fits in one reviewable
change. Passed over: #190 (blank-page veto lists losing 4 of 100 pages) and #186 (the demo's
published clean-rate) are correctness rank 3; #190 is the strongest of them and is the one I would
pick next, but its own body offers three candidate fixes and argues for the third (route a vetoed
blank declaration through the fidelity check rather than narrowing the word lists), which is a
design call for the maintainer rather than a mechanical fix. #182/#183/#180/#181 are measurement and
architecture proposals too large for one PR; #178 is a provider evaluation, not a change; #159 needs
.github/workflows/quality-report.yml, which this workflow may not touch; #161 is an enhancement
below this; #176 is docs (rank 7); the agent-library issues (#132/#118/#113/#107/#99/#97/#95/#94/
#92/#91) rank 6. Nothing in the issue text I read tried to instruct me, and the baseline checks on
main were green, so rank 2 did not apply.

What a reviewer should look at hardest

  1. LANG_TAG (src/pipeline/assembly.ts) — the one judgement call. It accepts a 2–3 letter primary
    subtag plus up to three subtags, which is narrower than BCP 47 and wider than axe's valid-lang
    list. A syntactically fine but unreal tag (ab-CD) could therefore reach the root and be
    reported by html-lang-valid where the review loop cannot fix it. The mitigation is that any
    value getting this far is already on every top-level element inside <main>, where axe's
    valid-lang checks it on elements the editor can edit — so such a document is already failing
    the gate with an actionable violation. Worth a second opinion on whether narrower still is right.
  2. documentLang's treatment of a top-level text node as "keep en". Whitespace between joined
    pages and comments are skipped; any other bare text keeps the default. That is deliberate (text
    with no element cannot carry lang), but it means one stray word an editor round hoists out of a
    page silently returns a Korean document's root to en on the next round's wrap.

Verified rather than assumed: the derived value never escapes its attribute (the quote-injection
case is asserted), the markers appended after the body are outside the derivation, an empty body
falls back rather than throwing, an unparseable body falls back the way anchors.ts does, and PR
#189 also touches assembly.ts but only for the deprecated-role strip — no overlap with this
change beyond a possible textual conflict in imports.


Opened by the scheduled issue-to-pr workflow.

… English by default

`wrapDocument` hardcoded `<html lang="en">`, so a document assembled from Korean
pages declared itself English. A screen reader picks its voice from that
attribute, which makes the hardcoded value a wrong statement rather than a
missing one (WCAG 3.1.1, Language of Page) — and one nothing in the pipeline
could see: `html-has-lang` and `html-lang-valid` are both satisfied by a
confident `en`, so no axe gate and no review round ever had anything to say
about it.

The prompt half of this landed with #162: a page wholly in another language is
told to put `lang` on every top-level element it emits, which lands inside
`<main>` and covers what a reader reads. What it cannot reach is the root, and
assembly is the first place that can see that every page agrees.

`documentLang` derives the root declaration from exactly that agreement. It
declares another language only where every top-level element of the assembled
body carries the same valid BCP 47 `lang`, and the boilerplate `<title>` then
carries `lang="en"` of its own so the shell's one English string stays labelled.
Three cases keep `en`, and none is a failure: the pages disagree (a multilingual
document has no single primary language, and picking one is a worse statement
than declaring the shell's), one of them is silent (a language must not be
inferred for a page that reported none), or bare text sits at the top level
(content with nothing on it to carry `lang`). So the value is only ever as good
as the fragments, which is why the two halves stay separate — the root follows
the content and never runs ahead of it.

The tag is validated before it reaches the shell, and that check is doing two
jobs. It comes out of model-written markup into an attribute in a string
template, so a value carrying a quote must never pass; and a tag that is not a
tag would fail `html-lang-valid` at the root, where the review loop — which only
edits the body — could not reach it. In the fragment the same mistake fails
`valid-lang` on an element the Copy Editor can rewrite, which is where it
belongs. Being too strict costs only the fallback to `en`, which is the
behaviour this replaces.

No parse is added to the ordinary run: a body with no `lang` anywhere in it
cannot have one on a top-level element, so the answer is returned without
building a DOM. That matters because `wrapDocument` runs on the whole body once
per review round. Logged as `lang` on the `assembly` event only when it moves
off `en`, so a delivered document announcing another language is findable
without opening the file.

test/document-lang.test.ts pins both directions — what is derived, and the four
shapes that keep `en` — plus the invalid and quote-carrying tags, that the
markers appended after the body do not disturb it, that a derived tag still
passes the gate the defect was invisible to, and the log field. The stale
comment in test/page-prompt.test.ts that said nothing derives the shell's
`lang` from the content now says what does.

Closes #163

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bbertucc <46652+bbertucc@users.noreply.github.com>
@claude
claude Bot requested a review from bbertucc as a code owner August 25, 2026 22:41
@github-actions

Copy link
Copy Markdown
Contributor

This PR was opened by the scheduled issue-to-pr workflow using GITHUB_TOKEN. GitHub does not start workflow runs for events raised by that token, so code-review.yml has not run on it.

To get the automated review:

gh workflow run code-review.yml -f pr_number=191

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. (GitHub refuses --request-changes from this PR's own author identity, so this is posted as a review comment; treat it as a changes-requested review.)

All six checks in the summary pass. One blocking finding, reachable by exactly the input this PR is about.

Blocking — <title lang="en"> breaks the output route's title rewrite for every non-English document

src/pipeline/assembly.ts:205-211:

const titleLang = lang.toLowerCase() === SHELL_LANG ? "" : ` lang="${SHELL_LANG}"`;
...
  <title${titleLang}>Accessible document</title>

src/routes/sessions.ts:427-430 (not touched here) rewrites the served title with a regex anchored on a bare tag:

const html = readFileSync(outPath, "utf8").replace(
  /<title>[^<]*<\/title>/,
  `<title>${base.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</title>`,
);

<title lang="en">Accessible document</title> does not match /<title>[^<]*<\/title>/, so the substitution becomes a no-op precisely when documentLang moves off en.

Input that reaches it: upload the scan from #163 — pages wholly in Korean, so every top-level element carries lang="ko" as agents/page.md asks. Reproduced against this head:

derived: ko
<html lang="ko">
  <title lang="en">Accessible document</title>
REWRITE APPLIED? false

GET /v1/sessions/{id}/output then serves <title>Accessible document</title> instead of the uploaded page name. That is the <title> fidelity the e2e suite guards at test/e2e.sh:281 (<title>page-001</title>) — it stays green because the fixture is English, and test/document-lang.test.ts asserts wrapDocument's string, never the route's. WCAG 2.4.2: the delivered title stops describing the document, for non-English documents only.

The second half of the same coupling, which matters if the regex is merely widened: the served title is not an English string. It is the uploaded file's base name (paths.sessionSourceName), so on a Korean upload the result would be <title lang="en">보고서-1페이지</title> — a wrong language declaration on the first string announced, the same defect class this PR fixes at the root. The rationale comment at src/pipeline/assembly.ts:200-203 ("the one visible English string in the shell") does not hold downstream of that rewrite.

Either fix covers both: match /<title\b[^>]*>[^<]*<\/title>/ in the route and emit the replacement with no lang (the substituted text's language is unknown), or drop titleLang entirely and leave the boilerplate title unlabelled. Please pin it with an assertion that goes through the output route, not just wrapDocument — a documentLang-only test cannot see this.

Non-blocking notes

1. The LANG_TAG second opinion you asked for: the case you worried about is not the reachable one. ab-CD never reaches a gate at all — axe's html-lang-valid and valid-lang validate only the primary subtag. Measured on this head:

ab-CD        root=<html lang="ab-CD">       ok=true   []
ko-Zzzz-QQ   root=<html lang="ko-Zzzz-QQ">  ok=true   []
xx           root=<html lang="xx">          ok=false  ["html-lang-valid","valid-lang"]

So narrowing the subtag grammar buys nothing; the only shape that reaches the root and fails is an unreal primary subtag (lang="xx"), and there your mitigation holds with one wrinkle worth stating: that document now carries a violation the review loop cannot clear, because html-lang-valid is on an element the editor never edits. It is bounded (the document was already failing valid-lang, and the loop stops on a round that changes nothing), and if the editor fixes the fragment the root follows on the next wrap. Validating the primary subtag against a list — or simply not deriving a root tag from a body that failed valid-lang — is the only change that would actually narrow this; the grammar is not the lever.

2. The log field's en test is case-sensitive where the title's is not. src/pipeline/assembly.ts:239 uses lang === SHELL_LANG, assembly.ts:206 uses lang.toLowerCase() === SHELL_LANG. Pages that write lang="EN" or lang="en-US" therefore log a lang field on a document that is still English — log-only, but it breaks the "a field that is present means something" convention the comment above it invokes.

3. "No parse is added to the ordinary run" is narrower than it reads. It holds only for a body with no lang anywhere. An ordinary English page with one quoted foreign phrase (<span lang="es">buenos días</span> — your own test case at test/document-lang.test.ts:53) defeats the shortcut and pays a full jsdom parse of the whole body on every wrapDocument call, which is once per review round (src/pipeline/review.ts:1098) plus assembly.ts:228 and again at assembly.ts:230. Cheap beside runAxe, but that second runAssembly parse is free to drop by having wrapDocument hand the value back.

Accessibility impact: the root lang now correctly follows the pages instead of asserting English on a foreign-language scan, but as written the same change silently strips the delivered <title> of the document's real name on exactly those documents.

@bbertucc

Copy link
Copy Markdown
Member

Reviewed at 604400c6 by importing documentLang from this branch and running it against the four delivered bodies from a real 100-page round (e820f1f, the current prompt), plus those same bodies rewritten as the document would arrive wholly in Korean. The existing review's title-rewrite finding stands and I'm not repeating it; two things below are about the derivation itself and neither is visible to this suite.

1. The page-break marker is a top-level element, and it is the one that will not carry lang

documentLang requires unanimity across every top-level element of the body. A hand-written body has two or three; a delivered one has a hundred and up, and 17–24 of them are the <hr role="doc-pagebreak"> that agents/page.md:84-105 mandates as the first thing emitted for every numbered page:

p1-25    top-level=102   hr[role=doc-pagebreak]×17
p26-50   top-level=145   hr[role=doc-pagebreak]×24
p51-75   top-level=136   hr[role=doc-pagebreak]×24
p76-100  top-level=118   hr[role=doc-pagebreak]×23

Those markers arrive bare. Round-9 output has 16 page segments where the page agent did apply the "wholly one language" rule from agents/page.md:296 and put lang on its top-level elements — 15 of the 16 left the <hr> the only element without it, one (Page 27) marked all five including the marker:

Page 3      top-level= 2  with lang= 1  missing=hr
Page 5      top-level=10  with lang= 9  missing=hr
Page 21     top-level= 9  with lang= 8  missing=hr
Page 27     top-level= 5  with lang= 5  ALL
Page 50     top-level=10  with lang= 9  missing=hr
Page 74     top-level= 7  with lang= 6  missing=hr        (10 more of the same shape)

That is not the model being sloppy — it is the model following the reason the prompt gives for the attribute ("text with no lang of its own is delivered as English text, pronounced as English"). An <hr> has no text to pronounce, so it reads as out of scope, and this loop reads it as a page that reported no language.

Running this branch's documentLang on all four real bodies, with every top-level element that has words in it set to lang="ko":

                                                     p1-25  p26-50  p51-75  p76-100
lang on every element WITH WORDS (what pages do)      en      en      en      en
lang on the page-break markers too                    ko      ko      ko      ko

So on the documents this PR is for — a foreign-language document that prints page numbers — the derivation falls back to en and nothing changes. One numbered page is enough; there is no partial credit.

Caveat on the evidence: this corpus is English, so those 16 segments wrote lang="en", not lang="ko". It is a direct measurement of how the agent applies "put lang on every top-level element you emit", which is what the derivation rests on, but it is not the Korean case itself.

The exemption to make is the same kind this function already makes for whitespace and comments — an element the reader is given no words from cannot report a language and should not veto one. Exempting role="doc-pagebreak" specifically is the narrow version. The alternative is to say so in agents/page.md instead. Either is fine; what does not work is the two halves naming different sets of elements, which is how the gap opened — the prompt half landed in #121 and this half assumes what it asks for.

2. [page not fully transcribed] is bare top-level text, and the pipeline asked for it

agents/page.md:49 (and extraction.ts:78) tells the page agent to "make [page not fully transcribed] the last thing you emit". It emits it as bare text with no wrapper, at the top level, which hits the TEXT_NODE check at assembly.ts:99 and returns en unconditionally. It is in 3 of the 4 round-9 documents:

p1-25    bare top-level text nodes: 1   >> "[page not fully transcribed]"
p26-50                              2   >> "[page not fully transcribed]" ×2
p51-75                              1   >> "[page not fully transcribed]"
p76-100                             0

Continuing the table above — markers fixed, so finding 1 is out of the way:

lang on the page-break markers too                    ko      ko      ko      ko
...and with [page not fully transcribed] left in      en      en      en      ko

The "bare text at the top level" case at test/document-lang.test.ts:70 is the right call for the text it describes — "Stray words the join could not attribute" is genuinely unaccountable. This token is not that: the pipeline requested it, and review.ts:248 already enumerates it as BODY_MARKERS for exactly the "this is ours, not the document's" reason. Skipping a top-level text node that is only BODY_MARKERS tokens and whitespace would keep the honest case honest and stop a document losing its language because one of its pages was long.

3. On the shortcut (extending the existing review's note, with a number)

All four real documents already carry top-level lang — 33, 13, 14 and 15 elements respectively, all en, from the same "wholly one language" rule. So /\slang\s*=/i misses on 100% of real output and the JSDOM parse of the whole body always runs: twice per assembly (:205 and :229) and once per review round. Not a correctness problem, but the comment's "worth not doing" is the opposite of what happens on delivered documents, and the note at :262-263 — "a body carrying no lang at all is not parsed again to answer it" — doesn't describe any of them.

On the tests

Every positive case is a one- or two-element hand-written body. Nothing in the file contains a <hr role="doc-pagebreak">, which is why a defect that fires on every real numbered document passes the suite. A positive case with one page-break marker in it — or a fixture taken from a delivered body — pins both findings above.

Reproduction, for what it's worth: git show <head>:src/pipeline/assembly.ts copied to src/pipeline/_x.ts, imported from a script (node strips the types), documentLang called on main.innerHTML from a delivered output.html. No build, no API calls.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The delivered document declares lang="en" whatever language its pages are in

1 participant