Files
duocthu/docs/progress-log.md
T

178 KiB
Raw Blame History

Progress Log

2026-08-05 (evening 3) — The LLM cloud is LIVE: DeepSeek generation + Cohere rerank on the real corpus

The owner rejected the $0 offline build as the deliverable and set a hard deadline. The chatbot is now a real LLM RAG, grounding kept ON, running the full HTTP stack (ai-service :8079 ↔ Postgres trace ↔ Qdrant; web :3000). Commit 9c4273b (plus 92497ae/9e9cef7/1b6f399 earlier this session, which committed the previously-uncommitted evening-1/2 work).

What was turned on (live via gitignored .env; committed defaults stay disabled/section-only so CI/fresh-clone never touches cloud):

  • EMBEDDING_PROVIDER=cohere-v4 — query now embedded in the corpus's cohere.embed-v4:0 space (probe: 1024-dim, L2 1.0, ~1.95s). No re-embed; the 15,100 vectors already exist.
  • ANSWER_PROVIDER=bedrock-converse + deepseek.v3.2 — new adapters/bedrock_converse.py (Bedrock Converse API, boto3, model-agnostic; Qwen/GLM = 1 env + 1 ARN). Probe OK. GPT-4o confirmed NOT on Bedrock; OpenAI gpt-oss, DeepSeek, Qwen, GLM, Mistral, Kimi ARE (checked live).
  • RERANK_ENABLED=truecohere.rerank-v3-5 trims the overview/similarity fallback: a free-form drug question no longer dumps all ~29 sections at the model (measured 29 → 6 on the fever/paracetamol case). Section route never reranks; fail-open (outage → book order, answer survives).
  • rag/prompt.py rewritten to current citation-enforced practice: each dose carries its population/condition label (no adult/paediatric mixing), cite only the supporting block, no [n] spam, abstain on insufficient evidence.

IAM: managed policy BedrockEmbeddingInvoke bumped to v4 (invoke on titan-embed, cohere.embed-v4, deepseek.v3.2, cohere.rerank-v3-5); repo file synced. Codex was off, no collision.

Verified: ai-service 111 passed, 3 skipped (+12 this milestone). Live HTTP POST /v1/rag/query returns a grounded LLM answer with a citation and a Postgres trace id. Golden golden_e2e (35 Qs): 19/19 answerable questions grounded with the correct drug (incl. typo paracetamon, alias Acetaminophen, multi-turn inheritance); 14 adversarial correctly abstained (fake drugs, weather, symptom→drug reverse-lookup, multi-drug). Two real gaps: a price question answers from the monograph instead of "no price in the formulary", and "should I double the dose?" is not directly warned. Every generated=True answer passed grounding.verify.

Cost/safety: Bedrock is pay-per-call — verified 0 EC2 (3 regions) and no provisioned throughput; idle = ~$0. A few dozen probe/smoke/eval calls this session, cents-scale on the estimate; exact bill not checked.

Separate track, NOT done (background subagent started, own worktree): reconstruct the 151 quarantined tables with a needs_expert flag on uncertain cells + parse Part 1 (poisoning/pregnancy/hepatic-renal) & Part 3 (BSA/ATC) + re-embed. This is a multi-hour ingestion pass with the whole-doc validation gate and will NOT be clinician-validated within the deadline — deliberately kept off the deadline path.

2026-08-05 (evening 2) — Conversational chat core wired LIVE end-to-end (offline, $0); owner wants the LLM cloud next

The chat core is now live and serving multi-turn, not just unit-tested. It runs $0/no-cloud because the section-route is a payload filter (no query embed) and generation is still off (verbatim), but the conversational behaviour is real and smoke-tested against the running service (ai-service :8079, web :3000).

Built (rag/conversational.py ConversationalLoopService, wrapping the safe GroundedAnswerService; wired through bootstrap.py/main.py/routers/rag.py with an optional conversation_id, plus route.ts sending it and a ChatPanel error state):

  • Multi-turn follow-up inheritance. "Chống chỉ định Metformin" then "còn trẻ em thì sao?" carries the drug+section forward and names it ("Về metformin: …").
  • Smalltalk. "chào bạn" gets a friendly redirect, not a failed-drug-lookup refusal.
  • Drug-name-only → the whole monograph. Typing "PARACETAMOL" now returns all 18 sections in book order with 【heading】s and per-section citations (QdrantRetriever.find_by_drug + SECTION_ORDER; RetrievalService uses it when a drug resolves but no attribute is named) — the earlier "specify an attribute" dead-end is gone.
  • Typo → ask, never threshold-guess. Only an EXACT drug name auto-resolves; a fuzzy match is offered as a question ("Ý bạn là: Metformin?") via CatalogDrugResolver.suggest(min_score=0.72). A completely-wrong name → "Không có thuốc này trong Dược thư Quốc gia." A formulary must not silently answer about a different drug than the one meant.
  • Autocomplete endpoint GET /v1/rag/suggest?q= (CatalogDrugResolver.complete, substring/prefix) — the frontend dropdown that consumes it is still to build.
  • BSA calculator rag/calculators.py (Appendix 1, DuBois, tested vs the book's own cells).

Verification: ai-service 99 passed, 3 skipped; live smoke test of all four conversation behaviours plus the monograph/typo/not-supported cases. A refine-loop bug (a refined query dropped the inherited drug and abstained, discarding a good answer) was found in my own code and removed before shipping — clarify + inheritance are the loop's value, retrieval-refine is not, and it is gone from the live path.

Owner's next-session directive (recorded in memory project-llm-cloud-plan): stand up the cloud LLM — semantic query embedding (EMBEDDING_PROVIDER=cohere-v4, already IAM-permitted) and answer generation (a cheap model, non-Anthropic OK, via a Bedrock Converse adapter, needs its ARN added to BedrockEmbeddingInvoke). The offline build was budget/safety-first, not LLM-avoidance; the owner wants the real AI experience next, with grounding.verify and the quarantine contract kept ON.

2026-08-05 (late) — Read the source book's own structure; scope + usage-pattern findings (checkpoint before handoff)

Read the Dược thư 2018 front matter directly (printed p8 "Nội dung", p39 "Hướng dẫn sử dụng") to understand what the book is for and how clinicians use it — recorded in memory reference-duoc-thu-2018-structure. Key facts that reshape the chatbot scope:

  • The book has three parts. The corpus is Part 2 (drug monographs, printed 991496) ONLY. Excluded and clinically important: Part 1 general chapters (printed 3798: prescribing in the elderly / hepatic-renal impairment / children / pregnancy-lactation; disease-class guidance for asthma, epilepsy, HIV, antibiotics, TB, hepatitis B, antipsychotics; drug allergy; poisoning & antidotes; drug-interaction principles) and Part 3 appendices (printed 14971528: body-surface-area calc, IV admixture, ATC classification). So "how to treat asthma", "antidote for X", "BSA-based dosing" have no data in the index today — a coverage limit, not a retrieval bug.
  • The 19 monograph fields are fixed and documented on p39; a field is omitted when the book has no info (so a missing section is not necessarily a parse bug).
  • Field 14 dose is a general adult+child oral reference dose unless stated; the clinician adjusts. → the tool supplies reference data, not a prescription.

Data checks run this session (against chunks.jsonl), correcting earlier pessimism:

  • Indication is searchable: 48 drugs' chi_dinh mention "sốt". Reverse lookup (symptom → drugs) is feasible from content, but retrieval is drug-first, so not answerable yet.
  • mg/kg dosing is in PROSE, not tables: 574 lieu_luong chunks contain "mg/kg", all prose, across 295 drugs, 473 of them mentioning trẻ em. So the primary weight/age dosing (incl. pediatric) is answerable; the 83 quarantined dosing tables are mostly the supplementary renal-adjustment tables (49 of those 83 drugs also have mg/kg prose).
  • Pregnancy dosing is mostly qualitative: 670 drugs have a thoi_ky_mang_thai section but only ~23 chunks carry a mg figure — the book rarely gives a separate pregnant dose, so answer = pregnancy caution + standard dose, never a fabricated pregnant-specific number.
  • drug_id can be compound (paracetamol_acetaminophen); alias resolution must map "paracetamol" → that id.

Design consequence discussed with the owner (not yet built): the "understand" stage must classify the turn type (smalltalk / medical query / multi-drug interaction / symptom-indication / out-of-scope / injection-shaped), not just resolve a drug. Refusing a clinician's symptom→drug question as "recommendation_out_of_scope" was wrong for this audience — such questions are indication lookups and should be answered from chi_dinh. Multi-drug interaction/contraindication questions need a real PLAN → gather both drugs → synthesize step (the ADR-0007 PLAN node, still unimplemented), and an absence-of-evidence answer must state where it looked, never assert "safe".

Session state / not yet done (so a fresh session can resume): the chat module's domain glue is built and unit-tested (rag/conversation.py ports + summariser, rag/conversational.py orchestrator + is_smalltalk); it is not wired to the endpoint. Live wiring (turn-type classifier, loop-around- GroundedAnswerService, Postgres store, conversation_id on /v1/rag/query, route.ts, ChatPanel error state), the P0 audit fixes (§5 context-mixing metadata, §8 Qdrant-error degradation), reverse-indication retrieval, and the table vision-consensus pipeline all remain to do. No code was wired live this session; the behavior spec is still being clarified with the owner before wiring.

Owner decision: parse the WHOLE book, re-chunk freely (not just Part 2 monographs). Current corpus covers physical pages 1001494 only. To add: Part 1 general chapters (physical ~3697) and Part 3 appendices (~14961527); front-matter list (13) and index (1529+) are already used as validation ground truth. Read segment/detector.py to ground the plan — the machinery already generalizes: a chapter title ("NGỘ ĐỘC VÀ THUỐC GIẢI ĐỘC") has the same shape as a monograph title (bold + mostly-upper + short), so is_monograph_title_candidate extends by widening the hardcoded 991496 range per content_type. Only two real changes: (1) parametrize the page range + add a content_type (monograph|chapter|appendix); (2) chapter/appendix sub-headings are free-form ("Hô hấp", "Co giật"), not the 19-key vocab, so detect_section_headings needs an open-taxonomy mode (bold + short = heading, store the text, no match_section requirement). Everything downstream (span extraction, table/formula quarantine, provenance, chunker) is content-type-agnostic and reused → schema v5 adds content_type + chapter_id. Gate (CLAUDE.md): the span-routing ledger must account for ALL 1668 pages with unassigned=0, not just 991496. Then embed only the NEW chunks (Cohere, pennies, announce first). This is a focused ingestion pass (detector + assembler + chunker + whole-doc re-run + validation), not a one-liner — not attempted this session beyond grounding the plan.

Done this session (App 1, self-contained, validated): rag/calculators.py body_surface_area_m2 replaces Appendix 1's lookup table with the book's DuBois formula (S = W^0.425 × H^0.725 × 71.84), tested against three of the book's own table cells (165cm/60kg→1.66, 90cm/10kg→0.50, 170cm/70kg→1.81) — tests/test_calculators.py, 3 passed. Audit §7 (calculation = tested function, never an LLM).

2026-08-05 (evening) — Conversational orchestrator wired to the existing loop; data-quality audit; budget verified live

Chat module (the glue ADR 0007 specified and nothing had called). Added the two missing conversation ports and their offline defaults to rag/conversation.py (ConversationStore/InMemoryConversationStore, Summariser/DeterministicSummariser) and the orchestrator rag/conversational.py (ConversationalRagService). It owns no rules of its own: load state → resolve this turn → inherit gaps from Focus → derive clarify signals from resolver state → reasoning.run_turn → update focus, append turns, summarise overflow, save → name any inherited drug. Runs with no LLM/service (collaborators are protocols). DeterministicSummariser records only drug/section labels, never cell values, so the no-clinical-content-in-summary rule holds by construction rather than by trust — closing the summary-bypasses-grounding hole flagged in review. 11 new tests; full ai-service suite 91 passed, 3 skipped.

Still NOT wired live: a TurnResolver bridge over CatalogDrugResolver + SectionResolver; bridges from RetrievalServiceRetrieve and the grounded generation path→Generate; PostgresConversationStore + migration; a conversation_id on /v1/rag/query; route.ts sending it and dropping the hardcoded intent: fact_lookup; a ChatPanel error state; and the multi-turn eval run. So no claim yet that history/loop improves answers — designed and unit- proven, not measured end to end.

Data-quality audit (self-run this session, not quoted from this log). 684 drugs; critical-section coverage is strong — dosing missing 0.1% (1), contra- indication 0.4% (3), indication 0%. But 83/684 drugs (12%) have their dosing inside a quarantined table, so a dose query for them returns VERIFY_PDF (crop, no number) — the largest answer-quality gap for a clinician audience, and it lands on the single most-asked query. 125 chunks carry a leading ": " label-leak artifact (93 in ten_chung_quoc_te). Vector-path text loss appears contained to 22 flagged lines (completeness of detection unverified). Nobody clinician-side has validated the 8.2M chars against the book — still the largest unmeasured area.

Table validation — the instrument that text extraction lacked. Demonstrated that vision reads a real quarantined dosing table cell-by-cell: GABAPENTIN's renal-adjustment table (printed 706) came back exactly by eye where pdfplumber's text layer could not structure it. Found and corrected a page-index off-by-one in my own render (data physical_page N = doc[N], 0-based) — proof that correctness must not depend on trusting coordinates. Strategy, given pharmacists are end-users, not labelers: reconstruction powers retrieval only; the displayed answer stays crop + page (clinician verifies at point of use). Validation is automated — vision↔geometric consensus + round-trip visual + book invariants — with a per-cell precision-first gate (disagreement → stays crop-only). Not yet built; 151 blocks is small enough for full census.

Budget, read live from the billing console (owner login; ai-lab-user has no billing API perms): $138.50 remaining, entirely AWS promotional credit, not the owner's card; August bill $0. Deploy target chosen: team k3s, but deferred (mutating a shared cluster). Generation still off (answer_provider= disabled) — extractive/verbatim, which is defensible for clinicians; wiring a cheap model (Nova/Haiku via Bedrock Converse) needs its ARN added to the BedrockEmbeddingInvoke policy, which today grants invoke on the two embedding models only.

2026-08-05 — An answer layer that cannot state a number the book does not

Today started by walking the demo path rather than the test suite, and the two are not the same thing. The suite was green and the demo was broken.

What the walk found, by running it rather than reading it. The backend answers real Vietnamese questions against the real embedded corpus with real citations and zero cloud cost — the section route is a payload filter, not a vector search. Chống chỉ định của Metformin là gì? returns the true contraindication text with one citation; Tương tác thuốc của Warfarin? returns two. But Tôi sốt cao, uống Paracetamol được không? returned HTTP 500: the similarity fallback reached Bedrock, which is revoked, and botocore.AccessDeniedException escaped as an unhandled error. Any question whose phrasing is outside the section phrase table takes that path.

That crash also re-verified the cloud shutdown today, live — the denial came from the service, not from a claim in a document.

Four defects, all fixed, all at $0.

  1. The 500. adapters/embedding.py now translates provider failures into the domain error QueryEmbeddingUnavailable, and RetrievalService catches it and abstains with reason="query_embedding_unavailable" — deliberately distinct from insufficient_retrieval_score, so an outage never reads as an empty corpus. rag/ still imports no SDK.
  2. A default config that does not work. config.py pointed at collection duoc_thu_chunks; the real one is duocthu_v1. embedding_provider defaulted to disabled, so /v1/rag/query returned 503 on a fresh clone.
  3. Neither existing provider was a safe default. local-smoke searches a SHA-256 vector against a Cohere collection — confident, meaningless hits. cohere-v4 spends the boto3 retry budget (~30s) before failing on a revoked account. Added SectionOnlyQueryEmbedder: refuses locally and instantly, so retrieval is confined to the route that measured 16/16.
  4. Safety abstention was incidental, not a gate. Symptom questions abstain with reason="drug_not_resolved" — because no drug name was found, not because anything recognised a symptom question. Recorded, not yet fixed.

The answer layer now has an LLM, and a check that makes "it does not fabricate" measurable rather than promised. Previously rag/answer.py was extractive: it concatenated retrieved chunks. That is why Liều Paracetamol cho người lớn? opened with 5 - 12 tuổi: Trẻ em 12 - 18 tuổi: — raw section text, paediatric doses first, for an adult question.

Generation is now three layers, and only the third is load-bearing:

  • Prompt (rag/prompt.py, domain — no SDK): evidence only, figures copied character-for-character, [n] citations required, insufficient evidence is a valid answer. Output shape is pinned by output_config.format, so a malformed envelope is the provider's error, not our parsing problem.
  • Verification (rag/grounding.py, pure domain): every numeric token in the generated answer must appear exactly in the evidence, and every [n] must resolve. Citation markers are stripped before number extraction so [2] is never read as the quantity 2.
  • Fail-closed (rag/answer.py): ungrounded number, invalid citation, malformed output, provider outage, or the model itself reporting insufficient evidence — every one falls back to the verbatim source text, which was computed first and is therefore always available.

Numbers are compared as strings, and that is the decision worth keeping. No parsing, no normalisation. 1.500 is 1500 under one reading and 1.5 under another; a normaliser that strips separators maps 7,5 and 75 to the same key, scoring a tenfold dose error as a match. Pinned by test_decimal_separators_are_not_interchangeable. The same rule refuses 2 g2000 mg: arithmetically right, but unit conversion is where dosing errors live, so it is refused rather than interpreted.

Quarantined tables and formulas are never generated over. VERIFY_PDF returns before generation — those are precisely the blocks whose numbers were not reliably reconstructed, so rephrasing them is the one case where fluency could invent a dose. This keeps ADR 0006's contract intact.

Provider chosen on the owner's instruction: AWS Bedrock + Claude. adapters/bedrock_claude.py is the only module naming the anthropic SDK, imported lazily. Two provider facts taken from the Anthropic API reference today, not from memory: Bedrock model ids carry an anthropic. prefix (anthropic.claude-opus-5), and the Messages-API path on Bedrock is AnthropicBedrockMantle, not the legacy bedrock-runtime InvokeModel route the embedding adapter uses. A stop_reason: "refusal" is a successful HTTP response with no usable content, so it is routed to the extractive fallback rather than allowed to raise on content[0].

This adapter has never been run against Bedrock. Cloud access is still revoked and no IAM change was made today. StubAnswerGenerator exercises the entire path — prompt build, schema parse, grounding check, fallback — with no cloud call, and that is what the end-to-end run below used.

Observability, because a dashboard is a better answer than a slide. rag/metrics.py defines the counters in the domain; adapters/prometheus.py is the only module naming prometheus_client, imported lazily; /metrics returns 404 rather than an empty 200 when metrics are off, so a scrape cannot succeed silently with no samples. The headline counter is duocthu_generation_rejected_total{reason="ungrounded_number"} — the measured form of the no-fabrication claim. A mismatched label drops the sample instead of raising: metrics must not be able to break a clinical answer.

infra/docker/ gains Prometheus and Grafana with a provisioned datasource and dashboard. Not yet verified running — the image pull was still in progress when this was written.

A section was being served scrambled, and only using the UI found it. liều dùng paracetamol opened mid-sentence on 5 - 12 tuổi: and buried Liều lượng: Người lớn: seven hundred words down. find_by_section returned whatever order Qdrant scrolled, and point ids are uuid5(chunk_id), so PARACETAMOL's five dosing parts came back 3, 4, 1, 2, 0 — verified by scrolling the real collection, not inferred. part_index was in the payload all along and simply never used. Now sorted by it; a part missing the field sorts last rather than being dropped, because a silently shortened dose list is worse than an unordered one. Pinned by tests/test_section_order.py, including the exact 3,4,1,2,0 case. This is a clinical defect, not a cosmetic one: a reader who stops partway through stops in the middle of a different population's dose. Every section-routed answer given before today — including the 16/16 golden result — was assembled in this scrambled order; retrieval picked the right chunks, so the measurement stands, but no statement about how those answers read survives it.

Conversational reasoning RAG: designed in ADR 0007, domain layer built. rag/conversation.py carries Focus (drug, section, population, verbosity, each stamped with the turn that set it) and the recent-turn window; rag/reasoning.py is the bounded loop. Both are pure domain and run with no provider, which is the point: which drug is this still about must be deterministic, not inferred.

Three rules make inheritance safe in a formulary, each pinned by a test: an explicitly named drug always beats context; focus older than six turns is dropped rather than carried, because a stale drug is a wrong-drug answer, not context; and any answer built on an inherited drug must name it.

The loop's uncertainty signal is not a model confidence score. It is the resolver states that already existed and previously dead-ended into abstain — ambiguous drug, unresolved attribute, multi-attribute question — which now produce a clarifying question. Deterministic, testable, and explainable to a reviewer in a way that "the model felt 0.73 sure" is not. A clarify signal short-circuits before any budget is spent, verified by asserting the budget is untouched and neither retriever nor generator was called.

Budgets are decremented before the call they pay for, so exhaustion degrades to the best answer so far. A retrieval round is bought only by a named gap with a genuinely new query: test_an_unnamed_gap_does_not_buy_a_round and test_a_refinement_that_changes_nothing_stops_the_loop are the guards against a loop that spins on a feeling or re-issues the same query.

Golden Dataset/golden_multiturn_v1.csv is new — 8 conversations, 19 turns, 6 of them inheritance-dependent. The existing golden file is single-turn by construction and can measure none of this. Includes the adversarial turns: a follow-up after a refused fake drug (must not borrow a drug from elsewhere), and a follow-up after a symptom question (must not inherit treatment intent).

Not yet wired: the loop is not called by GroundedAnswerService or the router, there is no PostgresConversationStore, and no evaluation run over the multi-turn file has been performed — so no claim is made that history or the loop improves answers. The design states how that will be measured; it has not been measured.

LangChain was considered and rejected. The repo already has the ports and adapters LangChain would supply, retrieval is already measured, and the guardrail is already domain code. Adopting it a week before a review would rewrite the working part for no measured capability gain.

Verification actually run: ai-service 56 passed, 3 skipped (37 + 3 before, +19); ingestion 296 passed, checked for regression, unchanged; duocthu_v1 holds 15,100 points at 1024-dim Cosine, matching the manifest; live service against the real collection answered three clinical questions with citations and abstained on six of the seven safety probes; /metrics scraped and returned duocthu_generation_served_total 2.0 and duocthu_abstention_total{reason="drug_not_resolved"} 1.0.

Not established, and load-bearing for the demo: apps/web is still entirely mockedpackages/api-client/src/sendChatMessage.ts:8 returns buildMockResponse(content) and the whole frontend contains no HTTP call to the backend, so the working API and the working UI are not connected; api-gateway, chat-service and auth-service hold 0 source files; the Bedrock generator has never been invoked; intent is still supplied by the caller, so the recommendation gate depends on the client declaring it honestly; and the Prometheus/Grafana stack has not been seen running.

2026-08-04 (evening) — Section routing: contraindication retrieval goes from 0.05 to 1.00, at zero cloud cost

The retrieval defect measured earlier today is fixed by routing rather than by embedding. No cloud call was made and nothing was re-embedded — Bedrock access is still revoked.

The change. A question that names its own attribute does not need similarity to guess which section answers it. rag/sections.py maps the question to a section_key; QdrantRetriever.find_by_section then filters on (drug_id, section_key) and returns every part of that section as a scroll, not a top-k. RetrievalService takes that route when it resolves and falls back to similarity otherwise.

Two rules carry the safety. Longest phrase wins: "chống chỉ định" and "chỉ định" differ by one prefix word and mean opposite things, so every phrase is sorted by length and the longer is tested first — the same rule keeps "quá liều" from being read as "liều" and "hướng dẫn xử trí ADR" from being read as "tác dụng phụ". No match is not a guess: an unrecognised question returns None and falls back rather than picking a section it is unsure of.

Measured against the real duocthu_v1 collection, no embedding involved:

similarity (measured this afternoon) section routing
hit@1, 160 generated cases 0.544 1.000
chong_chi_dinh 0.05 1.00
misroutes / empty / leaked sections 0 / 0 / 0

The generated 160 flattered it, and testing on human-written questions said so. Those questions use the phrasings the table was built from, so 160/160 is partly circular. Run against the 16 single-drug questions humans actually wrote in Golden Dataset/golden_e2e_v1.csv, the first version scored 10/16. The six failures were two gaps: four questions say just "Liều Metformin cho người lớn?" — bare "liều", which the table lacked — and one says "Bà bầu", a colloquial phrasing for pregnancy. Adding those phrases (no code change, which is what the open/closed table is for) took it to 16/16 while the confusable pairs still resolve correctly; bare "liều" is safe only because "quá liều" is longer and tested first, and there is a regression test pinning exactly that.

A circular import was found and fixed properly rather than worked around. service -> sections -> routing -> service, because normalize_name lived in routing.py. It is a text utility with no knowledge of drugs or sections, so it moved to rag/text.py; routing.py re-exports it so existing imports keep working.

Also wired, and still unproven: BedrockCohereQueryEmbedder replaces the SHA-256 hash embedder for the similarity fallback path. It has been import-checked only — never run against Bedrock — so the fallback path remains unverified end to end. The section route does not depend on it.

Verification actually run: ai-service 37 passed, 3 skipped (22 before, +15); ingestion 296 passed (unchanged, checked for regression); ruff --select F,E9,B,ARG over rag/, adapters/, bootstrap.py, config.py and tests/all checks passed; section-route evaluation against the live collection 160/160; human-written golden questions 16/16.

Not established: multi-attribute questions ("liều dùng và chống chỉ định") pick the longest phrase, which is deterministic but arbitrary; phrase coverage beyond these 16 human questions is unmeasured; and none of this speaks to whether the retrieved text is clinically correct.

2026-08-04 (afternoon) — First real embeddings exist; retrieval measured at 54% and the cause is not what the small sample said

The corpus is embedded for the first time. Bedrock IAM was opened on the owner's explicit instruction, all 15,100 chunks were embedded with cohere.embed-v4:0, loaded into Qdrant, and cloud access was then revoked and proven revoked before the owner's 17:00 deadline. Measured spend ~$0.49 of a personal $138 budget.

Gate results. 15,100/15,100 embedded; 15,100 points in duocthu_v1 over 59 batches; collection point count 15,100 — count gate PASS. Manifest records cohere.embed-v4:0, 1024 dimensions, Cosine, corpus SHA 04a27166eaa255b516829f8364227e65ad700e51446b569609d18b5efd11189c. Corpus SHA was re-verified against the morning audit before spending: identical, and identical to the post-lint copy, so the 12:05 chunker.py edit did not change output.

Both providers were probed live before choosing. Titan v2 and Cohere v4 each returned 1024 dimensions with a measured L2 norm of 1.000000. That settles a question left open since 2026-08-03: Cohere's normalized field was None because AWS's docs never state it. It is now measured. Cohere was chosen on two measured grounds — the corpus is Vietnamese and Cohere is explicitly multilingual, and bedrock_cohere.py batches 96 texts per request while bedrock_titan.py sends one, which at a measured 2.3s per call is ~9.6 hours versus minutes. The $0.41 price difference did not drive it.

The retrieval number, and a correction to a claim made earlier the same day. A 160-case evaluation (20 per section, 8 sections, questions generated from the corpus so labels are structural) measured hit@1 0.544, hit@3 0.663, hit@5 0.738. Per section:

section hit@1
chong_chi_dinh 0.05 (1/20)
chi_dinh 0.30
tac_dung_khong_mong_muon 0.40
lieu_luong_va_cach_dung 0.60
qua_lieu_va_xu_tri 0.65
than_trong 0.65
tuong_tac_thuoc 0.80
thoi_ky_mang_thai 0.90

An earlier 15-case run gave a similar headline (0.533) but led to the wrong diagnosis: four of its seven failures were contraindication questions answered with indications, so the cause was reported as embedding weakness at negation. At 160 cases that pair accounts for only 3 confusions. The dominant mechanism is different and larger: duoc_ly_va_co_che_tac_dung absorbs questions from every other section — 10 from adverse effects, 8 from contraindications, 7 from dosage, 5 from indications. It is the largest section (1,896 chunks) and describes the drug in general terms, so it sits close to almost any question about that drug. This is the small-sample failure mode CLAUDE.md warns about, reproduced on this project.

Re-embedding cannot fix this, and the capability to fix it already exists. Verified by reading the code, not assumed: apps/ai-service/adapters/qdrant.py search() filters on drug_id only and lets vector similarity choose the chunk; rag/routing.py resolves drug and intent but not section; and find_by_payload — the "return the whole section" method in ingestion/load/ — is never called anywhere in apps/ai-service. Attribute questions therefore depend on similarity picking the right section, which is what measures 54%. The fix is to resolve the attribute to a section_key and retrieve that section whole; ATTRIBUTE_TO_SECTION already exists in embed/benchmark_local.py.

A silent-failure hazard found and closed. apps/ai-service embedded queries with LocalHashQueryEmbedder — SHA-256 of tokens, explicitly plumbing only — while the collection now holds Cohere vectors. Querying across those two spaces returns hits and raises nothing; the results are simply meaningless. BedrockCohereQueryEmbedder was added and wired behind EMBEDDING_PROVIDER=cohere-v4. It has only been import-checked — never run against Bedrock, because cloud access was revoked first, as instructed.

Two operational lessons, both paid for. bedrock_runtime.py set no boto3 timeout, so a single throttled response held a socket open for over five minutes and stalled the whole run; connect_timeout=10, read_timeout=60 plus standard retries fixed it. Then the first full run still died at ~14,600/15,100 because the retry backoff (2s, 4s) was far shorter than a per-minute token quota needs. The disk cache made that survivable: the resumed run recorded 14,977 cache hits and 123 misses, so only 123 vectors were paid for twice — zero, in fact, since the first run's work was already saved.

Cloud shutdown, verified rather than asserted. Both policies detached and deleted; InvokeModel and ListFoundationModels both now return AccessDeniedException. No EC2 instance, no EBS volume, and — because the policy never granted CreateProvisionedModelThroughput — no way for this identity to create the one Bedrock resource that bills hourly.

Not established: retrieval quality is not acceptable for clinical use, no clinician-authored release gate exists, the generated evaluation questions use template phrasing rather than real clinical language, and no LLM answer layer has ever run against real evidence.

2026-08-04 — Chunk schema v4 passes the embedding-readiness gate

Reviewed the live Claude coordination and its last changes before editing. The delivery plan was objectively stale: it still described schema v2/15,076 chunks, empty embed/load/API modules, embedding before content-safety gates, and allowed unverified inferred table headers as retrieval text. The plan and ADR 0004/0006 now put content safety, exact provenance, fail-closed schema validation and local pseudo-vector smoke tests before any provider call. Bedrock remains benchmark- only and requires separate owner approval for any paid/full-corpus run.

Implemented schema v4 and regenerated the canonical chunk artifact. Retrieval text may repeat route/population labels so continuation chunks remain safe in isolation; contiguous source_text remains byte-reassemblable and drives exact physical/printed page provenance. context_labels records retrieval-only prefixes. All 151 unverified table/formula descriptors embargo header_row and cell-like column text. Attachments now carry physical page, printed page, block_id, bbox and optional crop, and those region references survive the Qdrant adapter and RAG citation response. The loader accepts exactly schema v4, rejects booleans/non-integers/out-of-range pages, and keeps the normalized LF/CRLF-stable corpus identity.

Canonical artifact measured after regeneration:

  • 15,100 chunks: 14,949 prose + 151 block descriptors;
  • 4,105,382 cl100k_base tokens; 0 chunks above the 800-token ceiling;
  • all chunk-ready gates pass: exact provenance, source uniqueness, reassembly, attachment coverage, descriptor embargo and schema checks all have 0 failures; 151 descriptors match 151 quarantined blocks;
  • raw file SHA-256: 8dfae08ae6d9222089c5cdb4207a064fe67989f10f7552b555af0aef6331d9a1;
  • normalized corpus SHA-256 used by the Qdrant manifest: 04a27166eaa255b516829f8364227e65ad700e51446b569609d18b5efd11189c.

Verification actually run:

  • ingestion: 292 passed; focused post-lint patch: 26 passed;
  • AI service with RUN_INTEGRATION=1: 25 passed, including real local Qdrant, PostgreSQL and FastAPI round-trips;
  • full canonical local smoke with deterministic 4D pseudo-vectors: first and second loads both upserted 15,100 records and both held exactly 15,100 points; manifest hash matched; data and sidecar test collections were removed and Qdrant returned to 0 collections;
  • Ruff F,E9,B,ARG on the files changed for this gate: clean; git diff --check: clean (Git only reported Windows LF/CRLF conversion warnings).

Conclusion: the canonical corpus is technically READY TO EMBED, meaning its input/schema/provenance/load plumbing meets the measured gates. This does not authorize a provider call, does not establish retrieval quality for any model, and does not prove whole-book medical accuracy. Human-reviewed clinical eval, table reconstruction, and recall for borderless tables/bar-less formulas remain outside what these gates prove.

2026-08-04 — Real local datastore plumbing, guarded RAG API, and printed-page citations

Read the live Claude Code process and coordination before editing. Claude owned ingestion/load/ and embed/cache.py; it completed the disk cache, Qdrant port/adapter, idempotent UUID5 upsert, payload indexes and corpus-SHA manifest. Its real local Qdrant scale check loaded all 15,066 chunk records twice with 1,024-dimensional deterministic pseudo-vectors and held the point count at 15,066. Those vectors are not embeddings and establish no retrieval-quality claim. No Bedrock call, IAM change, or cloud spend occurred.

Built the first runnable apps/ai-service boundary: FastAPI /health and POST /v1/rag/query, a Qdrant retriever filtered by resolved drug_id, a PostgreSQL trace repository plus migration, structured human/non-human scope and fact/recommendation intent gates, parent hydration, quarantine handling, and an extractive answer layer. The answer layer refuses evidence that has only a physical page; citations expose only the printed folio, chunk id and optional source crop. Quarantined tables/formulas return a PDF-verification warning and never auto-extract numeric content.

Fixed the missing provenance at its source. Chunk schema is now v3 and cli chunk reads the real folio map from the 1,668-page PDF. It refuses a monograph whose physical range cannot be mapped, and chunk-ready has a new chunk_without_printed_page_range gate. Regenerated scope: 684 monographs, 15,066 chunks (14,915 prose + 151 descriptors), zero oversized, and 15,066/15,066 records with a two-value printed-page range. New artifact SHA: e474c83790b450d3262f532e81abf6526a485e3a98e376413247da23f4619c38.

Verification actually run:

  • python -m pytest -q and Ruff over ingestion/: 258 passed, lint clean;
  • python -m ingestion.cli chunk-ready: every gate passed, including printed page range 0/0 failures;
  • ai-service with RUN_INTEGRATION=1: 22 passed, including a real chunk round-trip through local Qdrant, PostgreSQL migration/insert/read-back, and a full FastAPI → Qdrant → guarded citation → PostgreSQL trace round-trip;
  • local Docker services: PostgreSQL 16 and Qdrant 1.18.3 reachable; integration collections were UUID-scoped and removed after tests;
  • ArgoCD local: namespace, CRD and seven controller pods are running; the existing unrelated guestbook lab app is Synced/Healthy with four history entries. This repo's three Application YAML files parse and point to master/the Helm chart, but they are not installed and the chart still has no workload templates, so project sync/rollback was not performed.

Still open: no real embedding exists, no full canonical Qdrant collection can serve semantic search, population_tags are absent, no clinician-authored release-gate cases exist, and the API currently has no production answer/query embedding provider. The local hashing provider is explicitly plumbing-only.

2026-08-04 — Load stage built and proven against a real Qdrant; bbox rounding found

ingestion/load/ was a 0-byte __init__.py. It now holds the vector-store boundary: a VectorStore port, an InMemoryVectorStore that is the reference implementation of its contract, and QdrantVectorStore as the only module that names qdrant_client — imported lazily, the same arrangement that confines boto3 to bedrock_runtime. embed/cache.py was added alongside it.

Three design decisions are worth carrying forward.

The cache key is (model_id, input_kind, text_sha256), not chunk_id as §4.A of the delivery plan proposed. Measured reason: chunks.jsonl holds 15,066 records but only 14,869 distinct texts, so 197 records (1.31%) are repeats that a chunk-keyed cache would pay for twice. The content key also cannot serve a stale vector after an edit — a changed text is a changed digest, so it is a miss.

Point ids are uuid5(chunk_id). A random id would make a re-run append a second copy of a dose and nothing would report an error.

The corpus manifest lives in a <name>__manifest sidecar collection rather than a reserved point inside the data collection, because qdrant_point_count != chunk_count is a v1 gate and a gate needing an "except the manifest" footnote will eventually be read wrong.

Whole-corpus check against a real server. A local Qdrant 1.18.3 was started from infra/docker/docker-compose.yml (local container, no cloud) and all 15,066 real chunk records were loaded with deterministic pseudo-vectors at 1,024 dimensions — a check of the loading mechanism, not embeddings, which still do not exist. Corpus sha256 30d5154273e0959a…. First load: 15,066 points in 59 batches, 14.0s, point-count gate PASS. Second load: still 15,066, so idempotency holds at real scale, not only against the fake store.

That sha is already stale, which is the point. chunks.jsonl was regenerated at 09:53 the same day — chunker.py changed two minutes earlier and every chunk gained printed_page_range, 18,229,918 → 18,753,003 bytes, sha now e474c83790b450d3…. Re-measured on the new artifact: still 15,066 chunks, 0 over the 800-token ceiling (largest exactly 800), all 15,066 carrying printed_page_range, 14,915 prose + 151 block descriptors, 197 duplicate texts (1.31%) unchanged because only a field was added. Suite 258 passed. Had the old corpus been embedded and loaded, then the new one loaded into the same collection, two generations would have mixed with no error at query time — A6 is what refuses that, and it now has a real instance rather than a hypothetical one.

A sampled check passed and was wrong. Comparing 5 payloads gave 5/5 identical. Scrolling the entire collection instead found 86 of 15,066 chunks whose payload did not equal its source record. Classifying every differing leaf: 96 differences, all floats, all inside attachments[].bbox, maximum absolute delta 5.684e-14, and zero non-float differences — every text, id, page number, page range, token count and boolean round-tripped exactly. A PDF point is 1/72 inch, so that delta cannot move a rendered crop. It is pinned by a regression test that fails if the loss reaches another field or grows past 1e-9.

The layer responsible was isolated rather than assumed: the source chunks.jsonl returns the value exactly, our own json.dumps/loads returns it exactly, and Qdrant reached over raw HTTP with no SDK involved returns it one ULP low. Nothing needs re-chunking — a regenerated corpus would carry the identical value and be rounded identically. Qdrant also stores dense vectors as float32, so precision beyond f32 is discarded at load regardless.

Cache format was decided on measurements, not preference: 300 real chunk texts at 1,024 dimensions cost 21,098 bytes/record — ~318 MB per model for the corpus, with a 7.8s offset-index rebuild per open. float32 .npy (62 MB) and base64 float32 in JSONL (~87 MB) were measured and set aside; append-only JSONL survives an interrupted run and stays readable, which outweighs disk at one or two models. Revisit at three (~950 MB). It lands in ingestion/data/processed/, already excluded by .gitignore:34.

A gap in this work, found and closed the same day. Payload indexes were created on drug_id, section_key, atc_codes and chunk_kind and reported as done — but VectorStore had no query method, so all that was really proven is that create_payload_index returns without raising. Filtered retrieval is the whole of mode A. find_by_payload now exists on the port and both stores, as a scroll rather than a search: it returns every match, never a top-k, because "return the whole section" is the plan's non-negotiable — two of five contraindications reads as a complete list. Verified on a real server: all five parts returned with no leak from the PANTOPRAZOL/OMEPRAZOL pair that measures cosine 1.000 on contraindications; a deliberately 300-part section (above the 256 scroll page) comes back whole so paging cannot truncate; and a real multi-part section from chunks.jsonl round-trips to exactly its own chunk ids.

Tests: 255 passed with Qdrant running (206 before this work, +49); 247 passed, 8 skipped with it stopped, so an offline machine and CI see skips rather than failures. After the mode A work and the other worktree's cli.py fix the suite stands at 268 passed and ruff --select F,E9,B,ARG reports no findings at all across ingestion/.

Still missing, and deliberately so: printed_page_range and population_tags are not in the payload (open questions to Codex in coordination/CLAUDE_TASK_2026-08-04.md); cli embed / cli load are not wired because cli.py is Codex's; and no real embedding vector has ever been produced — every vector the load path has carried was synthetic. The Bedrock request shapes remain documentation-derived and unproven.

Measured cost: $0. No Bedrock call, no IAM change, no cloud resource.

2026-08-03 — Bedrock embedding boundary built; IAM diagnosed, not yet opened

ingestion/embed/ was an empty __init__.py. It now holds the provider boundary the model benchmark needs: an EmbeddingProvider ABC that owns input validation, request-size batching and timing, and three adapters behind it — amazon.titan-embed-text-v2:0, cohere.embed-v4:0, and BAAI/bge-m3 as the zero-cost local control. boto3 is named in exactly one module and imported lazily, so the package imports and the whole suite runs with no AWS account.

Two design points are worth carrying forward. input_kind is a required argument, not a keyword: Cohere embeds corpus records and queries into different subspaces, and sending search_document for a query raises no error — recall just drops. And normalized is three-valued. Titan is asked to normalize and says so; the Bedrock docs never state whether Cohere's float vectors are unit-length, so that field stays None instead of guessing, and embed.probe prints a measured L2 norm to settle it on the first live call.

The AWS side is diagnosed and stuck. ai-lab-user has no inline and no attached user policy; its one group (AI-Lab-Group) grants EC2, IAM, ELB and VPC full access and nothing else. There is no bedrock:* grant anywhere on the identity — confirmed by running both list-foundation-models and invoke-model and reading the two AccessDeniedException messages. Two least-privilege policies are drafted in infra/aws/iam/ but deliberately not applied: that identity carries IAMFullAccess and could attach them itself, which is exactly why it was left to a human.

Consequence: every request-body shape in the two Bedrock adapters is derived from the AWS user guide (read today) and has never been accepted by the service. That is unproven, not verified. Tests: 22 new, all with a stub invoker and zero network; 203 passed overall, up from 181. Lint clean on every file added (--select F,E9,B,ARG); the one remaining finding is a pre-existing cli.py import owned by the other worktree.

Measured cost so far: $0. Nothing was embedded, nothing reached Qdrant.

2026-08-03 — Exact hard-10 gate and all-block table chunking experiment

Extended the isolated table/formula sandbox beyond the 100-page sample. An exact ten-block risk gate covered four cross-page pairs, a merged header, a fragmented fraction bar, and the bar-less ADENOSIN formula; all ten source crops were visually checked. The full run then processed all 151 canonical blocks: 141 physical tables, ten formulas, 133 logical table parents, 669 row children, and seven cross-page logical tables.

Full-scope visual inspection exposed a continuation bug: FAMCICLOVIR p647 and INSULIN p811 repeat their column headers, while other continuation pages start directly with data. The linker now distinguishes these cases; repeated headers are not emitted as data, and INSULIN's changed Phối hợp first-column meaning is preserved. Both branches have regressions.

The expanded, source-derived retrieval suite contains 2,436 cases. With drug and table/formula lane resolved before ranking, deterministic hybrid character TF-IDF measured 94.42% Recall@1, 99.79% Recall@5, and 96.90% MRR. Row questions were 94.82% / 100%; formula questions 100% / 100%. Five ambiguous whole-table questions fell below top five because the same drug owns several near-identical tables; production must clarify or route using an additional table anchor. Neural MiniLM is now opt-in and excluded from the default parsing gate.

Measured chunk design: table-parent tokens min/median/p90/p95/max = 66/188/441/678/1,893; only four of 133 parents exceed 800. Row children are 75-token median, 172 p95, 471 max. Keep every logical parent intact, index both parent and header-aware rows, never split a row, and hydrate row hits to the complete parent/source pages. Final checks: 181 tests passed, readiness 20/20, lint clean.


2026-08-03 — 100-page table/formula reconstruction and RAG sandbox

Built an isolated experiment under ingestion/scratch/rag-table-pilot without writing sandbox representations into the canonical corpus. The risk-stratified 100-page run reconstructed 120 tables and 10 formula regions, rendered and manually inspected all 130 crops, and linked four tables continued across page pairs 132-133, 646-647, 825-826, and 1373-1374.

The retrieval router fixes the drug and data lane before vector ranking. On 461 source-derived queries, hybrid row+whole character TF-IDF reached 92.62% Recall@1, 98.70% Recall@5, and 95.04% MRR. Cached English-oriented MiniLM was worse (88.29% / 97.18% / 91.92%). Eighteen row-hit answer previews all hydrated to the complete parent Markdown table; eight included both pages of a continued table. A narrow deterministic interval probe passed 172/172 generated cases; this is a mechanics check, not clinical ground truth.

Visual review exposed one canonical defect: ADENOSIN p147's bar-less printed formula region ended after its numerator and omitted Nồng độ adenosin (3 mg/ml). The bar-less band now extends 31pt below its synthetic anchor, capturing the denominator but stopping before Ví dụ:; a regression pins that boundary. Canonical artifacts were regenerated after the fix: 684 monographs, 11,974 sections, 15,066 chunks, 151 descriptors, 0 unassigned spans, all 20 readiness gates passing, 180 tests passed, and lint clean.

Decision: JSON grid + Markdown answer view, row and whole-table retrieval, and mandatory parent hydration are viable for the next stage. This remains a retrieval experiment, not production clinical approval; merged-cell semantics, unit/multi-axis reasoning, Vietnamese embedding comparison, borderless/bar-less recall, clinician-authored evals, and final expert review remain open.


2026-08-03 — Whole-corpus parser repair after manual baseline audit

Implemented and re-ran the parser over all 1,668 pages after manually reading the high-risk baseline outliers. The fixes are structural, with regressions:

  • restored the missing THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG GONADOTROPIN boundary (Tên chung quốc tế và mã ATC is its real first anchor), separating pages 13711373 from THUỐC PHIỆN - OPIAT - OPIOID;
  • require both physical and inferred printed page bounds, so back-index page 1655 can no longer extend ZOLPIDEM's real [1492, 1494] range;
  • keep plain label-shaped text as body when it is an adjacent wrapped continuation in the same PDF block (including NADROPARIN's “không phải là chống chỉ định”);
  • classify known table cells before headings, putting WARFARIN and IOBITRIDOL dosing tables back under lieu_luong_va_cach_dung;
  • added confirmed heading variants for CLORPHENIRAMIN dosage forms and tetanus toxoid dosing, and real provenance for combined inline fields;
  • made verified formula bands column-aware: NETILMICIN opposite-column prose is retained while AMPICILIN's gutter-adjacent formula stays quarantined;
  • visually inspected all 151/151 unique table/formula regions against the rendered PDF; every region is genuinely 2D and remains quarantined;
  • emit every physical table/formula region atomically at its first stream occurrence, fixing split/contradictory ownership on CAPECITABIN, IMATINIB, CARBOPLATIN, NETILMICIN, and TRASTUZUMAB;
  • route explicit Bảng N. Điều chỉnh liều ... appendices back to dosage even when the book prints them after Tên thương mại (CAPECITABIN p309);
  • added readiness gates for every individual section part's source-span IDs and duplicate physical-region IDs.

Final regenerated artifacts and evidence:

check result
tests 180 passed; lint clean
segmentation 684 monographs, 11,974 sections, 8,213,036 prose chars
back-index validation 96.2% recall (678/705), 99.1% precision
quarantined regions 151 blocks / 151 unique IDs, all visually checked
chunks 15,066 (14,915 prose + 151 block descriptors), 0 over 800 tokens
chunk readiness 20/20 PASS (including duplicate-region prevention)
coverage 252,799 spans, 0 unassigned across all 1,668 pages
residual ink 3,931 classified regions, 0 unclassified across all pages

Canonical ingestion/data/processed/{monographs,chunks,coverage_ledger} were regenerated. Remaining limits: no whole-document human-reviewed clinical ground truth, no row/column reconstruction for quarantined tables, and unknown recall for borderless tables/bar-less formulas. This is ready for retrieval experiments, not a claim of production clinical approval.


Chronological record of work done on this project, newest entry on top. The goal is continuity across sessions: if a work session ends unexpectedly (context/token limit, interruption), whoever picks this up next — human or Claude — should be able to read the latest entry and know exactly what's done and what's next, without having to reconstruct it from git history.

Convention: add a new entry at the top before ending a session whenever meaningful progress was made, and proactively the moment it looks like the session might run out of context/tokens mid-task — don't wait until the very end if that risk is showing.


2026-08-03 — Independent re-verification, a redundant rule in my own uncommitted fix, a provenance defect, and the measurements a retrieval design has to be built on

No code was changed in this session: the four files from the previous round are still uncommitted and under external review (Codex). Everything below is measurement, and the numbers live nowhere else — the investigation scripts were deleted per the repo rule, so this entry is the record.

1. Re-verified the whole tree from scratch

check command result
tests python -m pytest -q 164 passed, 39.31s
lint ruff check --select F,E9,B,ARG . clean
gates cli chunk-ready 18/18 PASS; 683 monographs, 11,966 sections, 8,212,880 chars, 167 quarantined blocks
recall/precision cli validate --pdf … 683 detected, 705 ground truth, 96.0% (677/705) / 99.1%
span ledger cli coverage --pdf … (re-run) 252,799 spans after merge, 9,398,772 chars, unassigned 0
reproducibility cli run → sha256 byte-identical to monographs.jsonl (84f41d96…)
reproducibility cli chunk → sha256 byte-identical to chunks.jsonl (63472db4…); 15,076 chunks (14,909 prose + 167 descriptors), 0 oversized, 4,072,725 tokens (cl100k_base)

Recall rose 92.9% → 96.0% because of the uncommitted back-index rejoin, and the mechanism is the denominator: 725 → 705 ground-truth entries once wrapped fragments stop counting as entries. The detector did not improve.

Not re-run: cli residual-ink. residual_ink.json is dated 2026-08-01 12:03, before the 17:36 assembler edits. Its stored contents (3,931 regions, no unclassified kind) are last session's numbers, not this session's.

Doc drift found: docs/verification-strategy.md quotes 252,733 spans / 177,679 normalized_text / 12,764 heading; measured today 252,799 / 177,754 / 12,752. The unassigned = 0 conclusion still holds.

2. The x0 geometry change in the uncommitted diff is redundant

Assembled the whole book four times with the two new rules toggled:

variant monographs sections chars
current (x0 + italic) 683 11,966 8,212,880
old x1 rule + italic 683 11,966 8,212,880 — 0 differences of any kind
x0, no italic 683 11,966 8,212,844 (3 sections differ)
x1, no italic (= bc01782) 683 11,966 8,212,780 (9 sections differ, 11,014 char delta)

The italic rule alone recovers all 9 sections (CEFAZOLIN dosing 1,062 → 5,620 chars; CALCI LACTAT than_trong 582 → 1,504, tuong_tac_thuoc 2,346 → 1,439). The x0 rule alone recovers 6 of 9 and adds nothing on top of the italic rule.

Worse, the justification is wrong: the real NEVIRAPIN span on physical page 1045 is TimesNewRomanPS-ItalicMT (verified by reading the span's font), so the italic rule is what fixes that page — not the 0.01pt overlap the code comment and the new test's docstring credit. The test itself is valid but pins the geometric rule only, because the _span() fixture helper never produces an italic font. Either keep the x0 rule as defence-in-depth with an honest comment, or revert it — but the current comment overstates it.

3. source_page_range is wrong for 13 of 683 monographs

Section-level provenance (parts) is correct everywhere; the monograph-level page range is not. 12 monographs overshoot by +1 page; ZOLPIDEM declares [1492, 1655] while every one of its sections comes from 1492-1494 — a 164-page claim reaching into the back index.

Root cause for ZOLPIDEM, confirmed: physical page 1655 (printed 1656, back matter) carries a bold span reading exactly Tương tác thuốc, which _classify emits as a _SectionEvent, and the _SectionEvent branch at segment/assembler.py:496 updates source_page_range[1] with no in_monograph_range guard — unlike the _TextEvent branch at line 511. Verified that 0 spans past physical 1495 pass in_monograph_range, so no text was contaminated and empty_section is still 0. The defect is confined to one provenance field.

The +1 cause is not isolated — it is not lifted tables (all 12 have tables: []); the likely candidate is a next-page boilerplate span bumping the range before being excluded, but that was not measured.

4. Corpus profile — what a retrieval design actually has to work with

  • 13 of 19 fields have p90 < 1,500 chars, i.e. the whole section fits one chunk. Only four routinely need splitting: duoc_ly (p90 4,939, max 14,099), lieu_luong (4,873 / 14,197), than_trong (2,419), tuong_tac (2,147). Confirms ADR 0004 on the cleaned corpus.
  • ATC: 668/683 (97.8%) carry ≥1 code, 171 (25.0%) carry more than one, max 20, 1,043 distinct codes.
  • ten_thuong_mai present in 492 (72%) monographs.
  • The back index holds 344 X - xem Y lines — brand → generic aliases — which parse_back_index currently discards wholesale (correct for validation, but this is the highest-value retrieval asset in the book, because clinicians type brand names).
  • 401 xem [thêm] mục/chuyên luận phrases across 261 monographs; a chunk containing one is useless retrieved alone.
  • Dosing population markers: Trẻ em 53%, Người lớn 51%, Người cao tuổi 15%, Trẻ sơ sinh 8%, Suy thận 8%, Suy gan 6% of 682 dosing sections — real sub-section boundaries, better split points than token windows.
  • 167 quarantined blocks, 129 (77%) inside lieu_luong_va_cach_dung — the most dangerous field is the one the tables were lifted out of.

5. Cross-drug confusability — the number that decides the architecture

First hypothesis (much repeated boilerplate across drugs) was refuted: only 171 of 11,966 sections share exact text with another drug (1.4%), and the six heavy clinical fields are 100% distinct.

Then measured, per field, each drug's TF-IDF cosine against its nearest other drug. This is a lexical proxy, not an embedding measure — it bounds the problem from one side only.

field median p90 p99 max drugs with NN > 0.7
lieu_luong_va_cach_dung 0.314 0.455 0.631 0.836 4 (0.6%)
tuong_tac_thuoc 0.284 0.461 0.870 0.984 18 (2.8%)
tac_dung_khong_mong_muon 0.300 0.437 0.856 1.000 13 (1.9%)
chi_dinh 0.408 0.637 0.885 0.924 31 (4.5%)
chong_chi_dinh 0.346 0.633 0.898 1.000 37 (5.4%)

Named pairs: PANTOPRAZOL ↔ OMEPRAZOL (contraindications 1.000, indications 0.913) · BENZATHIN PENICILIN G ↔ PHENOXYMETHYLPENICILIN (contraindications 1.000) · DIGOXIN ↔ DIGITOXIN (0.891 / 0.911) · NATRI NITRIT ↔ NATRI THIOSULFAT (dosing 0.631 — two different steps of the same cyanide-antidote protocol) · IOBITRIDOL ↔ ACID IOXAGLIC (0.984) · ESTRIOL ↔ ESTRON · GLICLAZID ↔ GLIMEPIRID · NAPHAZOLIN ↔ OXYMETAZOLIN.

Name layer: 19 drug names are a substring of another drug name (CLOROTHIAZID in HYDROCLOROTHIAZID, EPHEDRIN in PSEUDOEPHEDRIN, LORATADIN in DESLORATADIN, ATROPIN in HOMATROPIN HYDROBROMID — all genuinely different drugs), and 106 of 683 names share a 6-character prefix across 38 clusters.

Conclusion drawn from this, for the retrieval design: vector similarity must never be allowed to choose the drug — only which passage within an already-resolved drug. The dangerous confusions are concentrated in a small, enumerable set of same-class pairs, which is exactly the population this project's verification strategy says to census rather than sample.

Not done yet / next up

Sequenced in docs/v1-delivery-plan.md (written this session): a two-week plan to a running v1, scoped down to two deployables (web + ai-service) because the four NestJS services measure 0 .ts files each. The items below are the ones that plan depends on.

  • The confusable-pair census must become a committed fixture produced by production code (ingestion/validation/), not a deleted scratch script. Until then these numbers are only in this entry.
  • ADR 0007 (retrieval architecture) not written. Proposed content: vectors never pick the drug; the unit returned to the LLM is the complete section (enabled by section_not_reassemblable_from_chunks = 0, because a partial contraindication list reads as "no contraindication"); and eval split in two — routing correctness (ground truth derivable from the corpus itself, 683 × 19 pairs, no human needed) versus content correctness (requires a clinician; cannot be self-generated without fabricating evidence).
  • Entity/alias layer (683 canonical names + 344 back-index aliases + 492 ten_thuong_mai + 1,043 ATC codes) — zero-regret, needed by every architecture, must use longest-exact-match because of the 19 substring traps.
  • residual-ink re-run; verification-strategy.md numbers re-synced; regression test for parse_back_index (still has none); the source_page_range guard; the x0-rule comment decision.
  • Open question for the user, not a technical one: this is the 2018 edition; the 3rd edition (2022) exists. For a document with legal force over prescribing, staying on 2018 should be a deliberate decision, and it makes edition-independence a real requirement for the pipeline.
  • Still untouched: embed/, load/, Qdrant, ai-service, and the general chapters (printed 37-98) and appendices (printed 1497-1528), which remain outside the corpus entirely.

2026-08-01 (cont'd, 7) — "still errors?" — yes: two more real content-loss bugs, both in dosing sections

Asked whether errors remained after the previous round, the honest answer was that this session has found real defects every time it looked one level deeper. It looked again, and found two more.

1. Chunks ended on a bare population label, with the dose in the next chunk. split_sentences treats : as a sentence boundary and _OPENS_SENTENCE accepts a digit, so "Người lớn: 500 mg mỗi 8 giờ." splits after the colon. When the packer flushed at that point, the chunk ended on the label. Measured: 38 prose chunks, e.g. AMOXICILIN's ending on a Lyme indication followed by a bare Người lớn:. Retrieval on that chunk returns a population with no dose; on the next, a dose with no population. Outlier item 17 counted population markers on 1,121 of ~1,400 monograph pages, so this is the common shape, not an edge case. The packer now carries trailing label atoms into the next part instead of flushing on them: 38 → 2, and chunks ending on any colon 721 → 19.

2. A section name printed mid-line was swallowed as a heading — real text loss, in dosing sections. Chasing the last 2 of those 38 showed the defect was not in chunking at all. CISPLATIN (physical page 402) prints Suy thận: Chống chỉ định. inside liều lượng và cách dùng; the second half is itself a section name, so it was matched as a heading. The result: the renal-impairment contraindication disappeared from the dosing text and the section ended on a bare Suy thận:. ISOPRENALIN had the same shape. Same family as the FLUOROURACIL bug fixed earlier today, but that rule only covered a label directly under a heading and could not see this one.

Fixed geometrically: a real section heading opens its line, so a non-bold section name with another span printed to its left is body text. "To the left" is checked properly — same page/block/line and previous.x1 <= span.x0 — because the synthetic test fixtures place every span at identical coordinates, and a looser check passed on real data while breaking the AMITRIPTYLIN inline-value case.

Verified after the fix: CISPLATIN's dosing section contains Suy thận: Chống chỉ định. again, ISOPRENALIN's Trẻ em: is followed by its doses, and chong_chi_dinh is no longer polluted. Monograph and section counts unchanged at 683 / 11,966 — nothing was traded away for the recovery.

State: 18/18 gates pass, 163 tests (was 161), ruff F/E9/B/ARG clean, 15,077 chunks with 0 over the ceiling, 8,212,780 section characters.

Standing conclusion, worth writing down: every round of "is it clean now?" this session has ended with real defects found — five in the previous round, two in this one, and four of the previous five were in code written the same day. The gates and tests prove what those instruments can see. They do not prove the corpus is correct, and the largest unmeasured area is unchanged: content accuracy against the source, with no human-reviewed ground truth for 8.2M characters.

2026-08-01 (cont'd, 6) — Bug hunt after declaring "clean": the token count was wrong by 2x, 14.7% of chunks were over the ceiling, and two stage boundaries measured different pipelines

I had just reported the tree as clean. It was not. Going looking properly found five real defects, four of them in code written earlier the same day.

1. estimate_tokens was wrong by a factor of two, and the number it produced was reported. ADR 0004 sized chunks with len(text) // 4, described honestly as an estimate. Measured against cl100k_base on the real corpus:

estimate (chars/4) 2,115,427 tokens
real tokenizer 4,093,440 tokens
real/estimate median 1.95, p95 2.50, max 6.0
oversized by estimate 0
oversized in fact 1,884 of 12,838 = 14.7%, largest 1,645 tokens

Vietnamese diacritics cost several byte-pair tokens each. "0 oversized" was reassuring and false. chunk/tokens.py now counts with the real tokenizer, injected so the chunking logic stays testable without it, with a fallback of chars/2 that errs small rather than large.

2. The packer could exceed the ceiling on its own. Two causes, both measured on VORICONAZOL's tương tác thuốc: an atom of 710 tokens was left whole because it was under the 800 ceiling, and the overlap builder added whole atoms until the running total passed the budget, so a 251-token atom produced a 273-token overlap against a 65-token setting. 273 + 710 = 983. Atoms are now split against the 650 target, leaving room for overlap, and the overlap stops before exceeding its budget.

3. An over-long comma list was left as one atom. VORICONAZOL's interaction list is one "sentence" hundreds of drug names long. Truncated by an embedding model it reads as "this drug is not listed" — a false negative in the direction that matters. Split at commas, which is lossless for a list.

After 1-3: 0 chunks over the ceiling, verified by an independent tiktoken re-count of the written file, not by the pipeline's own number. 15,049 chunks (was 12,838 — the rise is real sub-chunking that should have happened all along).

4. cli validate measured a different pipeline than cli run. It used the raw span stream (no transcription repair) and passed no table regions, so recall/precision described a build that is not the one producing the output — the same class of mismatch already fixed for coverage. Now shares _extracted_and_repaired_spans and _region_index. Result after the fix is unchanged at 92.9% / 99.1%.

5. chunk/io.py dropped SectionPart when reading monographs back, so per-part provenance died at the stage boundary — against CLAUDE.md's explicit rule. Now carried: 12,290 parts across 11,966 sections.

Two new gates, and the gate itself was wrong twice before the data was. section_not_reassemblable_from_chunks rebuilds each section from its own chunks by removing the deliberate overlap and compares. First version joined chunk texts with a newline and reported 734 sections missing — the first one it named was present. Second version probed a 60-character head and reported 1, NAPROXEN, where the probe straddled an overlap seam that legitimately repeats text. The working version compares with whitespace removed, because each split seam loses exactly one space to .strip() (measured on ABACAVIR: two single spaces in a 4,232-character section, nothing else). It proves no character of content is lost, reordered or duplicated beyond the intended overlap. 0.

Also fixed: all 8 real lint findings (ruff --select F,E9,B,ARG) — five unused imports and three zip() calls without explicit strict=. The zips were the adjacent-pair idiom and not bugs; strict=False now says so. And the transcription splice could leave a fragment holding only a space, which showed up as two whitespace_only spans; dropped, and proven inert by the sha256 over every section's text being byte-identical before and after (6af13301…).

State after the hunt: 18/18 gates pass (10 corpus + 8 chunk), 161 tests (was 158), ruff F/E9/B/ARG clean, unassigned = 0, cli validate 92.9% / 99.1%, 15,049 chunks with 0 over the ceiling.

2026-08-01 (cont'd, 5) — ADR 0006 implemented: chunks now reference their lifted blocks; chunk/ runs for the first time; 16/16 gates green

Why this was needed, in one line: a chunk of a section whose table had been lifted was grammatical, complete-looking prose with the table absent and nothing marking the absence — silent incompleteness, in the section where 127 of 167 lifted blocks live (liều lượng và cách dùng, 76%).

Design is in docs/adr/0006-quarantined-block-references-in-chunks.md, written before any code. It resolves the item ADR 0005 explicitly deferred.

Implemented: ChunkAttachment (block_id, kind, shape, physical_page, bbox, quarantined, header_row) on every prose chunk, plus one block_descriptor chunk per block whose text is built only from metadata. chunk/io.py now reads tables (it silently dropped them before) and writes schema_version: 2.

chunk/ executed for the first time, whole corpus:

chunks 12,838 — 12,671 prose + 167 descriptors
prose chunks carrying a lifted block 185
oversized (>800-token ceiling) 0
estimated tokens (chars/4, an estimate) 2,115,427

The condition this work was accepted under — prose chunks must not change — was measured, not asserted. Built the corpus both ways and diffed:

check result
prose chunk count, both ways 12,671 / 12,671
chunk id sets identical yes
prose_text_changed 0
prose_nonattachment_field_changed 0

Only the two new fields differ. The change is strictly additive.

A gate caught a real defect in my own design within minutes of existing. block_text_leaked_into_chunk_text fired on AMIODARON (physical page 183): pdfplumber reported that table's first row as "Thời gian liệu pháp tĩnh mạch Liều 720 mg/ngày (0,5 mg/phút)"a dose, inside what it called a header, from an extraction never verified by eye, being embedded as retrieval text. Measured across the corpus: 42 of 124 simple-table headers (34%) contain a digit. Rule added: a header row is embedded only when no cell contains a digit and every cell is short enough to be a label. 76 of 167 descriptors (46%) keep a header under that rule; the AMIODARON one does not. A label with no digit cannot be mistaken for a dose.

Full gate suite, 16/16 pass — 10 corpus gates plus 6 ADR 0006 gates (section_block_without_chunk_reference, attachment_block_id_unknown, attachment_without_page_or_bbox, block_text_leaked_into_chunk_text, descriptor_chunk_without_attachment, descriptor_count_vs_block_count = 167/167).

Tests: 158 passing (148 → 158). chunk/ had no tests at all before this entry; it now has 10, including the prose-unchanged invariant and the numeric-header refusal.

Binding on ai-service, stated in ADR 0006 and not implemented here: a chunk with has_quarantined_content must make the answer say a table or formula exists at the cited page and surface its crop; a block_descriptor may be answered only with the crop; no chunk carrying a quarantined attachment may be used to state a numeric dose.

Still open: table row/column reconstruction (the opendataloader cell data is available and matches pdfplumber exactly inside the monograph range); recall for borderless tables and bar-less formulas; content accuracy against the source; the general chapters and appendices (9.6% of characters).

2026-08-01 (cont'd, 4) — READY TO CHUNK: transcriptions merged back into the text, cli chunk-ready gate suite green on all 10 gates, two more real data-loss bugs found and fixed on the way

The blocker is closed. The 1,116 transcribed characters are no longer a file beside the corpus — they are in it. ingestion/extract/repair.py splices each transcribed run back into the span stream geometrically, and every command that builds monographs now goes through the same repaired stream, so the ledger and the output describe one pipeline rather than two.

New gate suite, cli chunk-ready (ingestion/validation/readiness.py). Each invariant gets its own count and its own target — a single verdict would hide exactly what took this session to find. Run on the whole corpus:

gate count target
outlined_run_not_merged 0 0
known_corruption_string 0 0
formula_fragment_in_prose 0 0
pua_char 0 0
replacement_char_ufffd 0 0
empty_section 0 0
section_without_provenance 0 0
unflagged_quarantine_block 0 0
duplicate_drug_id 0 0
monograph_without_page_range 0 0

Corpus going into chunking: 683 monographs, 11,966 sections, 8,212,712 characters, plus 167 quarantined table/formula blocks held outside prose.

Two real bugs surfaced by building the gates, both fixed:

  1. A 4pt glyph in the column-overlap strip was assigned the wrong column. classify_column's two tolerance bands overlap between x=288 and x=319 and left was tested first, so a single at x=315 on physical page 714 was classified as left-column and could not be matched to its own right-column line. Độ ổn định stayed Độ n định even after the repair ran. Fixed by testing exact containment before tolerance. Invisible for a full-width block; only a narrow box exposes it.
  2. A plain body line that repeats a section name was read as a heading. FLUOROURACIL (physical page 681), verified by rendering the page, prints Thời kỳ mang thai / Chống chỉ định. and Thời kỳ cho con bú / Chống chỉ định.. Both body lines matched the section vocabulary, so both sections came out empty and the statement that fluorouracil is contraindicated in pregnancy and while breastfeeding was dropped entirely. Fixed narrowly: a non-bold label directly under a heading is that heading's body. Boldness still cannot be required in general (outlier item 20), hence the position constraint rather than a style rule.

A third placement bug was caught during the merge itself: PyMuPDF emits the text either side of a dropped glyph as one span whose box spans the gap, so splicing at span boundaries produced tuở ổi. repair.py now reads per-character boxes from rawdict and splits the containing span at the character offset the geometry indicates.

Whole-document re-measurement after all of the above:

check result
cli run 683 monographs, 51 runs merged (1,116 chars), 167 blocks lifted / 167 quarantined
cli validate 92.9% recall / 99.1% precision — unchanged
cli coverage 252,801 spans, unassigned = 0
cli chunk-ready 10/10 gates pass
tests 148 passing (145 → 148)

What these gates explicitly do NOT prove, printed by the command itself so it cannot be quoted out of context: content accuracy against the source (no whole-document human-reviewed ground truth exists), table row/column reconstruction, and recall for borderless tables and bar-less formulas.

Next: chunk/ still has no tests and has never been executed. Table reconstruction from the opendataloader cell data remains available and is not on the critical path.

2026-08-01 (cont'd, 3) — All 23 fraction-bar candidates read by eye (precision 69.6%), 51 outlined runs transcribed, 2D formulas quarantined; prose-leak gate = 0

All 23 fraction_bar_candidate regions were rendered and read. Verdicts, one page at a time:

verdict count where
real 2D formula 16 p43, p92 (×5), p202, p325 (×2), p349, p1042, p1043 (×2), p1132, p1402 (×2)
not a formula 7 p4 (×3 decorative underlines on the Ministry decision page), p63 (ruled box), p845, p878 (table cell borders), p1667 (rule above the colophon)

Precision of the candidate rule: 16/23 = 69.6%. That is why the verified list is a curated file (ingestion/data/verified/formula_regions_2d.json) and not the detector's raw output — a 70%-precise rule must not quarantine content on its own. 10 of the 16 are inside the monograph range.

A formula the detector cannot find, confirmed. ADENOSIN (physical page 147) prints Tốc độ truyền dịch (ml/phút) = 0,140 (mg/kg/phút) × trọng lượng cơ thể (kg) / Nồng độ adenosin (3 mg/ml) as three plain lines with no fraction bar at all — verified by rendering the region and reading it. No geometric signal exists to detect it; it surfaced only because a prose-leak gate matched its text. It is quarantined and flagged, and recall_limit in the verified file records that the number of bar-less formulas in the book is UNMEASURED. The fraction-bar scan must never be described as complete formula coverage.

51 outlined runs transcribed into ingestion/data/verified/outlined_text_transcriptions.json — 22 full lines plus 29 single glyphs, 1,116 characters recovered, each with page, bbox, the run's text and the extracted line it belongs to. Every value there is a transcription read off a rendered page, labelled as such, never extracted data.

The single-glyph runs are the nastier half of that defect. They are Vietnamese diacritic characters dropped out of lines that otherwise extract fine, so the damage is invisible downstream:

extracted actual
Độ n định: Độ n định
≥ 1 tu i ≥ 1 tui
Thuốc dùng tại ch : tại ch
i nồng độ glucose máu (thay đ)i nồng độ glucose máu

2D formulas are now quarantined in the pipeline. SHAPE_FORMULA_2D was added to the existing shape taxonomy and to QUARANTINE_SHAPES — an entry, not an edit to matching code. ingestion/extract/formulas.py loads the verified regions and grows each bar into a band covering numerator and denominator. Whole-book re-run:

gate result
verified formula regions loaded 17 on 10 pages
blocks lifted out of prose 169, 169 quarantined
formula_2d blocks 14
formula_fragment_left_in_prose 0
monographs 683 (unchanged)
cli validate 92.9% recall / 99.1% precision (unchanged)
tests 145 passing (139 → 145)

The side margin needed two attempts: at 4pt, AMPICILIN VÀ SULBACTAM's numerator Thể trọng (kg) stayed behind in the prose because its span box carries leading spaces that pull its centre left of the bar. Raised to 95pt with the reasoning recorded in the module: over-capturing a neighbouring line into a quarantined block is recoverable, half a formula left in prose is not.

Still open: the 1,116 transcribed characters are recorded but not yet merged back into the monograph text — the corpus still contains Độ n định; table row/column reconstruction is untouched (137 simple tables

  • 17 multi-header + 1 continuation remain quarantined); table detection recall for borderless tables is unmeasured; chunk/ still has no tests and has never run.

2026-08-01 (cont'd, 2) — Residual-ink check built and run whole-document; found a text-loss class no text-based check could see: 51 runs of type drawn as vector paths on 5 pages

What was built. ingestion/validation/residual_ink.py (production, plus a cli residual-ink command) renders each page, whites out every pixel covered by an extracted span, and reports the ink that survives. It needs no ground truth and no sampling. Measured: 0.06 s/page, all 1668 pages in under two minutes. Classification is a pure function over (region, PageContext) with an ordered rule list, so a new kind of residual is a new entry, not an edit.

Whole-document gate result — all 1668 pages, 3,931 residual regions:

kind regions
header_rule 1,649
text_as_vector_outline 1,061
table_frame 959
antialias_speck 220
fraction_bar_candidate 31
rule_fragment 10
header_band_fragment 1
unclassified 0

The finding: 51 runs of text on 5 pages exist only as vector outlines. Physical page 714 (GATIFLOXACIN) prints 17 full lines of ordinary prose that page.get_text() does not return, page.search_for() cannot find, pdfplumber does not return and opendataloader-pdf does not return. page.get_drawings() shows why: each line is a filled path of 1,126-1,831 items, shaped exactly like one line of type, in the body-text colour. Single glyphs appear the same way with 39-45 items. Recovery cannot be automatic — the paths carry no character codes — so ingestion/extract/outlined_text.py detects and reports them for transcription and never guesses.

physical page outlined runs
714 31
736 16
1373 1
1444 1
1445 2

All five are inside the monograph range. Two independent methods agree on the same five pages: the drawing-shape scan, and counting glyph-shaped leftovers in the residual mask. Sample of what is missing, read off the rendered page: "Nghiên cứu trên động vật, gatifloxacin gây ngộ độc cho thai." (p714), "(Typhoid, inactivated, whole cell), J07AP03 (Typhoid, purified" (p1445).

Three instrument bugs were found and fixed before any of the above was believed — the measuring device was wrong before the data was, three times:

  1. Horizontal banding merged the two page columns, so page 209's ADR table sat in a box whose centre fell in the gutter and matched no table region. Adding a column split then cut single table grids into their individual rules. Replaced with 2D connected components (scipy.ndimage.label).
  2. A glyph-count ratio was nearly reported as a data-loss measure. First pass gave "extraction ratio 0.6656, 835 pages below 98%". It was wrong: get_texttrace() counts glyphs painted outside the page rectangle — 4,717,407 of them, on pages that are visually blank. Clipping to the page rect gave 0.8023 and "1642 of 1668 pages below 95%", which was also wrong: Vietnamese diacritics are painted as two glyphs and extracted as one character, so the deficit is systematic and meaningless. Neither ratio should ever be quoted. The pixel-based check is the sound one.
  3. Mask padding of 1.0pt ate the fraction bars it was meant to find. Calibrated to 0.5pt against the two known formulas, verified not to add noise on a 10-page prose sample.

Incidentally this explains a long-standing note in ADR 0003: pdfplumber "scrambles reading order" on this document because it reads the off-page text that PyMuPDF correctly clips away.

Tests: 139 passing (129 → 139), including whole-document regression fixtures pinning the 51 outlined runs per page and the two fraction-bar widths (188.6pt on p1042, 118.1pt on p202).

Not done / next: the 31 fraction_bar_candidate regions on 15 pages have not been looked at yet, so no precision figure for them exists; the 51 outlined runs are detected and flagged but not transcribed, so that text is still absent from the corpus; 2D formulas are still not quarantined in segment/. unclassified = 0 means every region is named, not that every named verdict has been checked by eye — of the seven kinds, header_rule, table_frame, antialias_speck, rule_fragment and header_band_fragment were confirmed on sampled examples only.

2026-08-01 (cont'd) — Two 2D fraction formulas confirmed corrupted in output by reading the source page images; both tools are blind to them, so cross-tool agreement does NOT bound recall

Finding, visually confirmed on the rendered source, n=2: stacked-fraction formulas lose the fraction bar and emit the numerator before the =, so the division reads as multiplication.

drug physical page source (read from the page image) pipeline output
NETILMICIN 1042 Cl_cr (ml/phút) = [(140 - tuổi) x cân nặng (kg) (x 0,85 đối với nữ)] / [Nồng độ creatinin huyết thanh (micromol/lít) x 0,81] (140 - tuổi) x cân nặng (kg) (x 0,85 đối với nữ) Clcr (ml/phút) = Nồng độ creatinin huyết thanh (micromol/lít) x 0,81
AMPICILIN VÀ SULBACTAM 202 Cl_cr (ml/phút) = [Thể trọng (kg) x (140 - số tuổi)] / [72 x creatinin huyết thanh (mg/dl)] Thể trọng (kg) x (140 - số tuổi) Clcr (ml/phút) = 72 x creatinin huyết thanh (mg/dl)

Read literally, both now state that clearance is multiplied by serum creatinine. This is a dosing calculation in a renal-impairment section. The content is not quarantined and carries no formula flag — it flows into chunk/ as ordinary prose.

This corrects the weight I put on cross-tool table agreement earlier the same day. Measured: on physical page 1042 pdfplumber.find_tables() returns 0 regions and opendataloader returns 0 tables; the same holds for the formula region on page 202. The two tools agreeing on 112 shared table pages measures consistency on what ruling lines make visible, not recall — they share the blind spot. Agreement must not be reported as evidence of coverage.

Priority consequence: the 155 table blocks are already quarantined: true, i.e. contained — they cannot poison an answer today. The formulas are uncontained. Formula handling should therefore come before table reconstruction, which is the reverse of the plan written earlier today.

Population sizing, honest limits. A keyword scan of the output found 185 occurrences of "công thức", of which 93 are "công thức máu/bạch cầu/hồng cầu" (blood count, not mathematics) and many of the remaining 92 mean "formulation" (thành phần trong công thức). So keyword counting cannot size the formula population; only a detector with measured recall can. The two cases above are the first two regression fixtures.

2026-08-01 — Readiness check re-measured from the current artifacts (no code change): text coverage complete, tables quarantined, formulas still unhandled

Question asked: is the data ready to parse 100%, including formulas and tables? Every number below was recomputed in this session from the files on disk (ingestion/data/processed/{monographs.jsonl,coverage_ledger.json}) and from a fresh test run — none quoted from earlier entries.

check command / scope result
unit tests python -m pytest -q (whole ingestion/) 129 passed
monographs / sections read monographs.jsonl 683 / 11,966
table blocks in output read monographs.jsonl 155 blocks, 155 quarantined (simple_table 137, multi_level_or_merged_header 17, cross_page_continuation 1)
span coverage ledger read coverage_ledger.json, all pages 252,733 spans; unassigned = 0
ledger states same normalized_text 177,754 (8,183,182 ch) / out_of_scope 53,374 (897,692 ch) / heading 12,764 / boilerplate_excluded 4,976 / quarantined 3,862 / structural_excluded 3
page coverage ledger vs doc.page_count 1666 of 1668 pages carry spans
the 2 pages with no spans rendered physical 99 and 1666 at 110 dpi, read the images both genuinely blank (0 chars, 0 images, only a frame drawing) — not a loss
PUA left in output scan all 11,966 sections 0
U+FFFD in output scan all 11,966 sections 0 — closes the gap flagged in the previous entry as never measured

Note the block count differs from the previous entry's 148 — this is a recomputation from the current file, not a correction of a bug; the shape mix also differs from the 180-region whole-book classification because blocks are only the regions that fall inside the monograph range.

Answer: no, not ready for a "100% including formulas and tables" claim. What is closed: goal A (full coverage, nothing silently dropped) for the monograph text path — unassigned = 0, both uncovered pages proven blank. What is open, by name:

  • Formulas: no production stage exists. grep -il formula over ingestion/ingestion/ hits only chunk/sentences.py and cli.py; all formula work lives in scratch/. The only detector fired 3,405 fraction_bar hits on 837 of 1668 pages with precision never measured, so there is not even a trustworthy formula count, let alone reconstruction. 2D formulas currently linearise into section text unflagged.
  • Tables: detected and quarantined, not reconstructed. 155/155 blocks are quarantined: true — provenance kept, unsafe to cite. Borderless tables (BSA nomogram, catalog item 7) are invisible to pdfplumber by construction, so the miss rate is unmeasured and undetected tables still contaminate body text.
  • Out-of-scope regions unparsed: 53,374 spans / 897,692 chars (9.6% of ledger chars) — general chapters and appendices — are excluded explicitly but have never been structurally parsed.
  • Content accuracy vs. source never measured; 92.9% / 99.1% is boundary detection only, on an uncleaned 1064-entry denominator.
  • chunk/ still has no tests and has never been executed.

2026-07-31 (cont'd, 5) — Cleanliness audit before chunking: data is NOT clean; 5 defects measured whole-corpus, incl. ≥/≤ in dosing text lost as PUA glyphs (all 8 PUA codepoints visually confirmed)

Trigger: user pushed back on starting the chunk stage ("chưa chunk dữ liệu phải sạch"), correctly — chunking was about to run against text that had never been audited for content-level cleanliness. Only boundary detection had ever been measured, never the text itself.

Also fixed this session (small): cli.py crashed with UnicodeEncodeError on Windows cp1258 when printing Vietnamese drug names in validate's unmatched lists — the metrics printed first so past numbers were unaffected, but the tail of the report was lost. Added sys.stdout/stderr.reconfigure(encoding="utf-8") in main(). Re-ran cli validate: exit 0, Vietnamese renders correctly.

Timing measured for the first time (whole 1668-page PDF, PyMuPDF only): cli run = 2m10.6s, cli validate = 44.4s. Does not cover pdfplumber/opendataloader/docling cross-checks, which are not part of either command.

Boilerplate re-verified independently against output generated this session: 0 of 11,409 sections contain "DTQGVN" (was 1,374), 0 of 682 monographs affected. Also closed the previously-flagged gap of "never checked with a different signature": scanned for a bare 3-4 digit line (page number leaking without "DTQGVN" adjacent) — 204 sections matched, sampled 8, all legitimate content (cytochrom P\n450 split across lines, dosing values like 250 microgam/kg), not boilerplate. Scope limit: 8 of 204 inspected, not all.

Cleanliness audit — whole corpus, 682 monographs / 11,409 sections / 8,241,485 section chars (ingestion/scratch/cleanliness_audit.py, temporary, to be deleted once this finding is fully captured):

signal occurrences sections hit % sections
mid-sentence line wrap 99,501 8,197 71.8%
short fragment lines (<4 chars) 11,612 2,149 18.8%
bare-number lines 2,540 862 7.6%
flattened table rows 25 9 0.1%
PUA chars 86 41 0.4%

Confirmed: table content IS contaminating section body text. Real example — AMPICILIN's duoc_ly_va_co_che_tac_dung contains an antibiotic-resistance table flattened to 'Salmonella typhi\n378\n10,6\n 0,0\n89,4\nShigella flexneri\n120\n41,6...', losing all row/column semantics. The 0.1% figure is only what the all-numeric-row regex catches; the true table count is pending the inventory scan and will be higher.

Confirmed, patient-safety relevant: comparison operators in dosing text are being emitted as raw PUA codepoints. All 8 distinct PUA codepoints in the corpus were located in the source PDF, rendered to images, and read directly (not inferred from context):

codepoint count actual glyph visual evidence
U+F0B3 57 p.141 "trẻ em ≥ 10 tuổi"
U+F0A3 17 p.169 "liều ≤ 100 mg"
U+F061 5 α p.334 "Streptococcus α tan huyết"
U+F0AE 3 p.1027 "HCO₃⁻ + H⁺ → H₂CO₃ → CO₂ + H₂O"
U+F0D2 1 ® p.891 "Plasma Lyte® 56/5%"
U+F031 1 p.957 "alpha₁-acid glycoprotein"
U+F0AF 1 p.1033 "rhodanese ↓" (catalysis arrow)
U+F067 1 γ p.1352 "interferon - γ"

74 of 86 occurrences are ≥/≤ inside dosing or adverse-effect sentences — losing the operator changes clinical meaning ("liều ≤ 100 mg" vs "liều 100 mg"). Fonts involved: SymbolTiger (7 codepoints) and Symbol (1).

Chunk stage — partially built, then deliberately paused. Wrote ingestion/ingestion/chunk/ (models.py, sentences.py, chunker.py, io.py, __init__.py) implementing ADR 0004: (drug_id, section_key) unit, 800-token ceiling, sentence-boundary-aware sub-chunking. Not tested, not run, and must not run until the cleanliness defects above are fixed — chunking dirty text bakes the defects into embeddings. ADR 0004's own "hard prerequisite" (the boilerplate bug) is satisfied, but this audit found additional blockers it did not know about.

Strategy adopted for full-coverage parsing (written up in docs/full-coverage-parsing-plan.md): separate what is provably clean from what is not — chunk the clean text, flag-and-exclude untrustworthy tables/ 2D formulas with an exact excluded count, and prove nothing was silently lost via a character coverage ledger (every char on all 1668 pages must land in exactly one bucket: section text / table cell / formula region / out-of-scope / unassigned, with unassigned reported as a number plus page+bbox list). Note the plan explicitly distinguishes goal A (full coverage, nothing silently dropped — achievable) from goal B (proven 100% correct — requires manual ground truth for every table/formula, not achievable in one day).

Fixes landed after the audit above — new ingestion/ingestion/normalize/ stage (glyphs.py = the verified PUA map, text_flow.py = geometry-driven span rejoining). Root cause of defects 1-3 was one line in segment/assembler.py: body_lines.append(span.text.strip()) made every span its own line, so any visual line the PDF split into multiple spans (italic run, subscript, symbol font) became multiple lines. Text-level regex cannot distinguish a mid-word span split from a real line wrap, so the fix uses geometry instead — PyMuPDF's own (block, line) indices identify spans sharing a visual line, and the horizontal gap (SPACE_GAP_PT = 1.0) decides whether a space belongs. Assembler now collects Span objects and joins via normalize.join_spans + normalize.substitute_pua.

Whole-corpus re-measurement after the fix (same audit script, same scope — 682 monographs / 11,409 sections):

signal before after
mid-sentence line wrap 99,501 0
short fragment lines 11,612 7
bare-number lines 2,540 0
flattened table rows (numeric-row regex) 25 0
PUA chars 86 0

cli validate re-run after the change: unchanged at 682 monographs, 92.8% recall, 99.1% precision — normalization does not affect boundary detection. Tests: 119 passed (110 before; 9 new in tests/ test_normalize.py, covering the real corpus cases — cytochrom P450 subscript rejoin, (feline immunodeficiency virus) italic rejoin, ≥/≤ restoration in dosing sentences, unmapped-PUA reporting). One existing test (test_running_header_boilerplate_stripped_...) had its expected string updated: it encoded the old \n join for "...không nhai. Nếu" + "uống viên thuốc...", which is exactly the mid-sentence wrap being fixed; its core assertions (no "DTQGVN", no "1009") are unchanged.

NOT verified — total section chars dropped 13,224 (8,241,485 → 8,228,261, 0.16%). Reasoning from the code says this is separator characters only (same-line spans previously contributed a \n each, now join directly; strip() only ever removed whitespace and no span is dropped), so non-whitespace content should be unchanged at 6,552,254 — but this was reasoned, not measured. The character coverage ledger (below) is the instrument that would actually prove it and has not been run.

Whole-corpus table/formula inventory completed (17m17s, ingestion/scratch/inventory_tables_formulas.py, all 1668 pages):

  • 200 tables on 152 distinct pages, 0 page errors. Column distribution: 3 cols ×78, 2 ×72, 4 ×32, 5 ×10, 1 ×4, 7 ×3, 6 ×1.
  • 22 header-less-at-top continuation candidatesthis figure was wrong and is corrected below: classifying all 200 regions individually showed 17 of those 22 are not_a_table_full_page and 3 are not_a_table_degenerate, leaving 2 real cross-page continuations. Cause: the inventory's condition (starts_near_top AND header_textual_cells <= 1) is satisfied automatically by any full-page false-positive region — its bbox starts at y≈0, and its single cell is a long text blob rather than a textual header — so every non-table landed in the continuation bucket.
  • 0 all-numeric wide grids — but this is a detector limitation, not evidence of absence: the known BSA nomogram (item 7) has no ruling lines, so pdfplumber.find_tables() cannot see it at all.
  • Formula detector over-fires badly and its output must not be quoted: 3,405 fraction_bar hits across 837 of 1668 pages (half the book) is not credible as a formula count — the thin-horizontal-rect signal is evidently matching table rules/underlines/column separators. Precision was never measured; this confirms the standing warning that a bbox heuristic finds candidates, not formulas. small_font_numeric (2,583) is likewise unvalidated. Only the PUA count (86) from that scan is trustworthy, and only because all 8 codepoints were visually confirmed.

Section-name spelling variants — a large silent section loss, found and fixed. Scanned the whole monograph range for bold heading strings that do not match the vocabulary, ranked by similarity: 42 distinct near-miss strings, 542 occurrences. The dominant one is "Thông tin qui chế" (469×) — the book prints "qui" where its own documented template (and vocab.py) says "quy", so match_section returned None and the section was never opened. Measured before the fix: only 96 of 682 monographs (14.1%) had a thong_tin_quy_che section; 586 were missing it entirely (the text itself was not lost — it fell into the preceding section's body unlabelled — but the structure was, so a "thông tin quy chế của X" query could not retrieve it and citations would name the wrong section).

Two mechanisms were added rather than one long alias list:

  • SectionDef.aliases for genuinely different wordings ("Mã ACT", "Chống chỉ đinh", "Thời kì mang thai", "Hướng dẫn cách sử trí ADR", "Quá liều và xử lý", "Dược lí và cơ chế tác dụng", …).
  • _lookup_key() folds typesetting noise for every entry at once — all whitespace removed, case folded, and the U+00D0/U+0110 look-alike ("Ðộ" vs "Độ") mapped. This alone absorbs ~14 variants that would otherwise each need an alias: "Chỉđịnh", "Chống chỉđịnh", "Độổn định và bảo quản", "H ướng dẫn cách xử trí ADR", "Tư ơng kỵ", "Tác dụng khôngmong muốn (ADR)", "Thận trọng.", "Liều l ượng và cách dùng", …
  • Two near-misses were deliberately rejected and recorded in REJECTED_NEAR_MISSES so a later reader does not add them: "Thể trọng" (body weight, 0.84 similar to "Thận trọng"/caution) and "Tác dụng không mong muốn của opioid" (a drug-specific sub-heading, not the section).

Whole-corpus result after the vocabulary fix:

before after
monographs 682 683
sections total 11,409 11,966 (+557)
thong_tin_quy_che present 96 (14.1%) 567 (83.0%)
cli validate 92.8% / 99.1% 92.9% / 99.1%
tests 119 122

Over-joining check (the direction the rejoining work had not tested). First attempt used text patterns and had poor precision — sampled examples were mostly false positives ("Liều lượng có thể tăng…" is ordinary prose, "Lọ 10, 50, 100 ml" is a volume list, "Wolff - Parkinson - White" is a hyphenated name), so its counts are not reported here. Redone at the level where it can actually be judged — the geometry of the two visual lines being joined — with the same exclusions assemble() applies (bold headings and header-band boilerplate removed, since joins involving those never reach body text). Monograph range, 180,131 body spans → 154,683 visual lines, 102,798 joins performed:

category count % of joins
clean wrap 98,611 95.9%
indent change 1,983 1.9%
vertical gap > 16pt 514 0.5%
column change 634 0.6%
page change 567 0.6%
upward (column/page turn) 489 0.5%

Vertical gap at join points: median 12.1pt, p90 12.4pt — a tight single-leading distribution, i.e. the overwhelming majority are genuine wraps. But over-joining is real and it is concentrated in tables: physical page 109 shows a dosage-form table being concatenated cell by cell — 'Viên nén' + '1', '1' + '1 - 4', '1 - 4' + '8 - 12', '8 - 12' + 'Viên nang tác', '18 - 24' + 'Tiêm bắp', 'Chưa biết' + 'Tiêm tĩnh'. This confirms the risk case predicted before the check was run, and it settles an ordering question: table regions must be excluded before joining, not after. Not all 1,983 indent-change cases were inspected — at least one sampled case ('…(ức chế' + 'alpha-glucosidase).') is a correct wrap with a hanging indent, so that category's precision is unmeasured.

Span-level coverage ledger built and run whole-document. Implemented as an optional ledger argument to assemble() plus a cli coverage command, at span level rather than character level (characters cannot balance because normalization joins and substitutes them). All 1668 pages, 252,733 spans after merge:

state spans % spans chars % chars
normalized_text 181,616 71.9% 8,231,038 87.6%
out_of_scope 53,376 21.1% 897,724 9.6%
heading 12,764 5.1% 221,266 2.4%
boilerplate_excluded 4,976 2.0% 47,609 0.5%
unassigned 1 0.0% 21 0.0%

The single unassigned span is "CÁC CHUYÊN LUẬN THUỐC" on physical page 98 — a part-divider title excluded on purpose via PART_DIVIDER_TITLES.

This also settles the previously-unverified 13,224-character delta: raw_chars_before_merge = 9,397,658 equals the post-merge total exactly, so the span-merge step loses no characters; the delta was separator characters in section assembly, as reasoned earlier but now measured.

Important limit, learned the hard way in the same session: the ledger proves every span was routed, not that routed content survived into the output. The section-overwrite bug below was invisible to it — spans were correctly marked normalized_text, then their section was overwritten downstream.

Table isolation wired into assemble() and gated. assemble(spans, table_index=...) diverts spans inside a real table region into Monograph.tables (a new TableBlock with table_id, shape, physical_page, bbox, section_key, quarantined). Gate results over the whole book:

gate result
non_table_span_changed 0
table_span_in_normalized_text 0
unintended_duplicate 0
section_emptied 0 (was 1 before the overwrite fix)
unassigned 1 (the deliberate part divider)
lifted blocks 148, all with unique ids
quarantined 148 / 148

Quarantine policy was widened per review: every multi-column shape (simple_table, multi_level_or_merged_header, cross_page_continuation, grid_2d_numeric) is quarantined until a real row/column reconstruction exists, because linearised cells are not safe to cite. Only single_column_boxed_list is exempt — one column linearises correctly.

183 regions loaded but only 148 blocks lifted is explained, not a loss: 1,676 table spans sit on pages outside the monograph range (e.g. physical page 42, in the general chapters), where no monograph is open to attach them to. Those pages are still out of scope entirely.

Three real bugs found by these gates, all fixed:

  1. Section overwrite destroyed content in 33 monographs (38 occurrences). A repeated section heading inside one monograph replaced the existing SectionSpan, discarding everything captured before the repeat. CEFAMANDOL's lieu_luong_va_cach_dung held only 172 characters of flattened renal-dosing table; after the fix it holds 881 characters of real dosing prose ("Cách dùng Thuốc được dùng dưới dạng cefamandol nafat…"). Sections are now concatenated, with the first heading kept as the provenance anchor. Other affected monographs include CEFAPIRIN NATRI and CEFRADIN — also dosing sections.
  2. Duplicate table_id. A region flushed twice emitted two blocks with the same id; provenance ids must be unique. Now suffixed (p339_t0, p339_t0#1). Verified: 148 blocks, 148 unique ids.
  3. Table blocks were never written to disk. write_monographs_jsonl had no tables field, so all 148 lifted blocks were computed, reported in the run summary, and then silently dropped at the file boundary. Found only because a check script raised KeyError: 'tables'. Fixed with a round-trip test.

Tests: 129 passing (122 → 129).

682 → 683 explained. A faithful reconstruction of the pre-fix vocabulary (old match_section/match_section_with_inline_value patched into the importing modules, no aliases, no whitespace folding, no Ð/Đ mapping) reproduces exactly 682; the current code gives 683. The difference is one monograph: CARBAMAZEPIN, physical pages 315-319, ATC N03AF01, 18 sections, anchor "Carbamazepine.". No monograph disappeared (GONE is empty) and it occurs exactly once, so this is a recovered false negative, not a duplicate — it is the same "Carbamazepin, 316" entry that cli validate had been listing as unmatched ground truth. Two earlier attempts at this comparison were invalid and their numbers (683/683 and 589/683) should be ignored: the first left aliases in _PREFIX_CANDIDATES and kept the new _lookup_key, the second built old-style lookup keys but still queried them through the new whitespace-stripping key function.

Not done yet / next up: Design revised (per review feedback, and it is the better design): make it a span/fragment-level ledger first and aggregate characters from it, because normalization joins, substitutes and drops characters so a pure character count cannot balance. States: normalized_text, table, formula, boilerplate_excluded, out_of_scope, quarantined, transformed_with_mapping, unassigned.

  • PUA reporting should be stated as known_mapped / unknown_pua / replacement_char_U+FFFD counts; only pua_chars = 0 has been measured, U+FFFD has never been checked. All 200 table regions classified individually, then the "not a table" verdicts checked by rendering every one of them and reading it. This is recorded in full because the first two counts reported in this area were both wrong, and both were wrong the same way — stated from metadata before anything was looked at:
  1. "22 header-less-at-top continuation candidates" — wrong, see the correction above; the real figure is 4.
  2. "22 of 200 are not tables" — asserted from rules (area ratio ≥ 0.75, n_rows <= 1 or n_cols <= 1) without opening a single page.

Rendering all 22 and reading them showed 20 correct, 2 wrong:

  • Correct (not tables): p1 copyright page; p3, p5, p1529 blank pages; p7 table of contents; p9, p10 committee member lists; p12 Vietnamese/English drug-name list; p1665 back index; p55 ×3 epilepsy classification lists; and p172, p196, p382, p760, p944, p1034, p1230, p1336 — ordinary two-column monograph prose that pdfplumber.find_tables() reports as one page-sized table.
  • Wrong: p62 and p72 are 1×3 regions with visible cell rules — real orphaned continuation rows of tables broken across a page (outlier-catalog item 5). The n_rows <= 1 rule discarded precisely the case where losing content hurts most, since a row without its header cannot be interpreted at all.

classify.py now treats only n_cols <= 1 as degenerate and routes a single row with several columns to cross_page_continuation. Corrected whole-set result:

shape count
simple_table 154
multi_level_or_merged_header 22
not_a_table_full_page 17
cross_page_continuation 4
not_a_table_degenerate 3
real tables 180
not tables 20

Verification scope, explicitly: all 20 non-table verdicts were confirmed visually, one page at a time. The 180 real tables' individual shapes (simple vs multi-level header vs continuation) are rule-derived only and have not been checked by eye — that classification must not be reported as verified.

Is "200 tables" trustworthy? Partly — and the limits matter.

  • No truncation: 200 records across 152 distinct pages (max 5 on one page, spanning physical pages 1-1665). Re-running find_tables() over just those 152 pages reproduces exactly 200. The round number is a coincidence, not a cap. But this is a reproducibility check with the same tool and settings, not independent validation.
  • Detection recall, measured against the book's own captions: 33 pages carry a "Bảng N" caption; 32 of them have a detected table → 97% on the captioned subset. 102 detected-table pages carry no caption, which is expected (most tables here are unnumbered). This measures recall only on captioned tables — borderless tables are invisible to pdfplumber by construction (the BSA nomogram, outlier item 7, is the known example), so the true total is ≥180 and the miss rate for unruled tables is unmeasured.
  • The single captioned miss is physical page 55, captioned "Bảng 2: Phân loại quốc tế các cơn động kinh (1989)". Rendering it showed the classifier's structural verdict was right (one column) but the label not_a_table_degenerate was semantically wrong — the book numbers it as a table, and it is a nested numbered list drawn inside a ruled frame. The shape was renamed single_column_boxed_list and is counted as a real region: single-column content linearises correctly, so it belongs in the text, unlike a 2D table. Naming it "not a table" risked a later reader discarding it.

New ingestion/ingestion/tables/ stage (models.py, classify.py, detect.py, io.py): table-region detection is production code, not a scratch script, even though its output is cached (detection takes ≈17 minutes). pdfplumber is confined to this module — ADR 0003 established it must never be used for text on this document. Not yet wired into assemble(); spans inside table regions are still flowing into section body text.

  • Table handling: 200 tables are known but nothing consumes them yet; they still flow into section body text as flattened cells (the numeric-row regex now reads 0 because rejoining changed the line shape the regex keyed on — that 0 does not mean tables stopped contaminating body text, and claiming otherwise would be wrong).
  • Formula detector needs a real precision/recall measurement against a golden set before any of its counts are usable.
  • Whole-corpus table/formula inventory (ingestion/scratch/ inventory_tables_formulas.py) was still running when this entry was written — no counts available yet; docs/full-coverage-parsing-plan.md has [chờ đo] placeholders that must be filled from a real run.
  • chunk/ has no tests yet and has never been executed.
  • Ground truth is not cleaned: cli validate's 1064-entry denominator includes repeated cross-reference index lines (e.g. "- CoA reductase, 285" appears 10+ times in the unmatched list). ADR 0003 used a 725 denominator, so 91.7% and 92.8% are not directly comparable. Neither number should be quoted as settled until the ground truth is cleaned.
  • Text content accuracy vs. source has still never been measured; the recall/precision figures measure monograph-boundary detection only.

2026-07-31 (cont'd, 4) — Follow-up on the character-diff's remaining unexplained low-similarity pages: sampled 6, all benign/already-known, none newly investigated pipeline bugs

Scope: of the ~30-50 pages below 0.95-0.98 similarity left unexplained by the reversed-column-order investigation (2 entries below), sampled 6 — 1498, 309, 382, 699, 1420, 1369 — chosen to cover the two visible clusters (1498-1529 near the back-index transition; scattered monograph-range pages) rather than just the very lowest scores.

Findings, all benign, none a new production-pipeline bug:

  • 1498, 699: table/formula content — opendataloader-pdf restructures it into markdown tables/headings, PyMuPDF's plain text flattens it; same underlying content, different presentation. Matches the already-documented "no table reconstruction implemented yet" gap (outlier catalog items 7-8), not a new finding.
  • 309: the two tools attribute different dosing tables to this page (PyMuPDF: "Bảng 4" single-agent; opendataloader: "Bảng 3" capecitabin+docetaxel combination) — a table-boundary/page-attribution disagreement between the two tools, same known gap as above.
  • 382, 1420: the two tools' plain-text page-content genuinely differs (different sections of the same drug appear to land on "this page" per each tool). Directly checked against the actual production pathway (extract_spans(), dict-mode, already column-sorted) rather than trusting the plain-text diff alone: production output for both pages matches PyMuPDF's own plain text exactly — the disagreement is opendataloader-pdf choosing a different page-boundary cut for overflow text, not a defect in this project's pipeline.
  • 3, 5, 97: near/fully blank pages (10-27 chars on one side, 0 on the other) — low information content makes the similarity ratio noisy at this scale regardless of correctness, not evidence of a real problem.

Honest scope limit: only 6 of the ~30-50 unexplained pages were sampled. All 6 turned out benign or already-documented, which is reassuring but is not the same claim as "all remaining pages are benign" — that would need the full set checked, which this session did not do. Investigation scratch files deleted per CLAUDE.md now that this finding is captured here.


2026-07-31 (cont'd, 3) — Fixed the boilerplate-leakage bug flagged by the parallel chunking-design session; independently re-verified their numbers before touching any code

Context: the parallel session below (ADR 0004 / chunking design) found and measured a real bug but deliberately left the fix to this session to avoid a same-file collision. Before writing any fix, independently reproduced their exact numbers from scratch (not trusted on read) — matched exactly: 682 monographs, 11,409 sections, 1,374 sections (12.0%) containing a literal "DTQGVN" string, 671 monographs (98.4%) affected, and the exact MORPHIN SULFAT liều lượng và cách dùng text they quoted. This is the same discipline applied earlier this session to a mid-session Riboflavin listing error found in this file — re-verify a reported finding directly against real data before building on it, even when it looks correct.

Root cause, confirmed: extract/spans.py already tags the running header ("DTQGVN 2" + page number + repeated monograph name) as column="full_width", but nothing in segment/assembler.py's classification pass excluded it — it matched no section heading and isn't a real all-caps title, so it fell through into plain body text, landing mid-sentence whenever a section's text crosses a physical page boundary. This is exactly outlier-catalog item 13's already-documented risk ("strip the fixed boilerplate before parsing content"), which had a warning but no enforcing code or test until now — added as item 22 in the catalog (item 23 also added for the reversed-column bug from the entry below, which hadn't been given a catalog number yet either).

Fixed: new assembler._is_page_boilerplate(span) — drops any span with column == "full_width" and y0 < HEADER_BAND_Y (same header-band threshold page_map.py already uses for folio detection; exported that constant as public rather than duplicating the magic number) before any other classification. Regression test added using the real MORPHIN SULFAT span shape (tests/test_segment_assembler.py).

Whole-corpus re-measurement after the fix: 0 of 11,409 sections contain "DTQGVN" (was 1,374). cli validate unchanged: 682 monographs, 92.8% recall, 99.1% precision — the fix only touches body-text content, not monograph/section boundaries. 110 tests total (was 109), all passing.

Not done yet / next up:

  • Chunking (ADR 0004, the parallel session's design) can now safely run against real ingestion output for this specific defect — but see the entry below's own "not done yet" list (sub-chunk splitter not built, general-chapters/appendices scope, sub-compound tagging) for what's still actually blocking Phase 2 beyond this fix.
  • Only checked for the literal "DTQGVN" substring as this bug's signature — did not separately verify whether the page-number token alone (without "DTQGVN" adjacent) ever leaks in some other layout shape; the fix itself is structural (column+y-position, not text-pattern-based) so it should cover that too, but this wasn't independently re-measured after the fix with a different detection signature.

2026-07-31 (cont'd, parallel session) — Phase 2 chunking strategy designed (ADR 0004) from real per-section measurements; found and flagged a new whole-corpus boilerplate-leakage bug for the extract/segment session to pick up

Context: this entry comes from a second session running in parallel with the one still fixing extract/segment parsing bugs, on the same checkout (no worktree separation). Per explicit scoping agreed with the user, this session touched only docs/adr/0004-chunking-strategy.md (new), docs/architecture.md's chunking paragraph, this log entry, and a since-deleted scratch script — it did not touch extract/*.py, segment/*.py, or docs/document-profile.md, to avoid colliding with the other session's in-flight edits to those files.

Done:

  • Ran python -m ingestion.cli run for real (full 1668-page PDF) to produce ingestion/data/processed/monographs.jsonl (682 monographs — gitignored output, matches the count already reported elsewhere in this log), then measured real per-section text-length distribution across the whole corpus for the first time (ingestion/scratch/chunking_stats_survey.py, now deleted per this project's investigation-script rule, findings captured below and in the ADR).
  • Replaced the never-validated chunking guess in docs/architecture.md ((drug, section) unit, ~500-800 tokens, 400-tok/50-overlap sliding window — written before segmentation existed) with a design grounded in the real measurement: (drug_id, section_key) chunk unit confirmed; 800-token ceiling (chars/4 estimate) confirmed as directionally right (clears ~16/18 section types at p90); but sub-chunking is the routine path, not a rare hedge, for 2 specific sectionsdược lý và cơ chế tác dụng (242/678 monographs with that section, 35.7%, max ≈3542 est. tokens) and liều lượng và cách dùng (200/675, 29.6%, max ≈3631 est. tokens); a smaller tail also exceeds it (thận trọng 3.7%, tương tác thuốc 3.4%). Chosen sub-chunking method: sentence-boundary-aware sliding window (~600-700 tok/sub-chunk, ~50-80 tok overlap), not a blind character/line window — assembler.py's body_lines join one PDF visual line-wrap per line, not a semantic boundary, so a blind window risks splitting a dosing sentence mid-way (a real, measured risk given outlier item 17: adult/child dosing splits appear on 1,121/~1,400 monograph-range pages). Full rationale, extended chunk metadata schema (chunk_id, atc_codes, part_index/part_count, etc.), and 4 explicitly-flagged open gaps (sub-compound tagging inside class-level monographs, sub-chunk page-precision, the splitter itself not yet built, general-chapters/appendices chunking out of scope) are in docs/adr/0004-chunking-strategy.md.
  • Found and measured a new whole-corpus bug, not yet fixed, flagged here for the extract/segment session rather than fixed directly (per user's explicit choice this session, to avoid a same-file collision): running header/footer boilerplate ("DTQGVN 2" + page number + repeated drug name — tagged column="full_width" in extract/spans.py) is never filtered out of section body text; assembler.py appends every non-title, non-section-heading span to body_lines regardless of column tag. Measured whole-corpus: 1,374 of 11,409 sections (12.0%) contain a literal "DTQGVN" string mid-text; 671 of 682 monographs (98.4%) have at least one affected section. Real example: MORPHIN SULFAT's liều lượng và cách dùng reads "...Nếu\nDTQGVN 2\n1009\nMorphin sulfat\nuống viên thuốc..." — the page number and drug name are spliced mid-sentence into a real dosing instruction. This is docs/pdf-parsing-outlier-catalog.md item 13's already-documented risk ("header/footer boilerplate must be stripped"), just never actually measured/fixed until this session — it should become a new numbered item in that catalog (item 22, or the next free number by the time this is read — check the catalog directly) with these numbers, but that file is mid-edit in the other session so this entry leaves the actual catalog edit to them rather than risking a concurrent-write collision. Note: this bug is separate from the reversed-column-order bug documented in the entry directly below this one — that bug was about which column content lands in, this one is about full-width header-band content never being excluded from body text regardless of column. This is a hard blocker for Phase 2: chunking must not run against real ingestion data until this is fixed, or boilerplate gets baked into embeddings and can surface mid-sentence in a chunk shown to a doctor/pharmacist.

Not done yet / next up:

  • The boilerplate-leakage bug above needs a real fix in extract/segment (likely: exclude column="full_width" spans from body-text assembly, or an explicit boilerplate-pattern filter) plus a regression test and a whole-corpus re-measurement to confirm it's actually gone — not done by this session, left for whoever owns extract/segment next.
  • ingestion/ingestion/chunk/ still doesn't exist — ADR 0004 is a design only; implementing and unit-testing the sentence-boundary splitter is a separate task.
  • Chunking design for general chapters (pp. 37-98) and appendices (pp. 1497-1528) is still blocked on docs/document-profile.md's Group 2 investigation (tables, 2D stacked-fraction formulas) completing.
  • Sub-compound tagging inside class-level/multi-ATC monographs (25.5% of corpus) has no design yet — flagged in ADR 0004, deferred to golden-dataset-driven eval.

2026-07-31 (cont'd, 2) — Built a whole-document cross-tool character-diff QA check; it found a real, serious cross-monograph data-corruption bug (reversed column reading order), now fixed and whole-corpus-reverified at zero occurrences

Why this check was built: after the ATC-field bug-fixing session below, the user asked what validation step would catch whether parsing is "correct" at all — not just "does cli validate say recall/precision are high," since that check only confirms a monograph exists at roughly the right name/page, not that its content is complete and correctly attributed. Per feedback-rigorous-validation, comparing PyMuPDF's own output against itself can't validate itself — a second, independently-implemented parser is required as real ground truth. Built a whole-document (all 1668 pages) per-page character-similarity diff: PyMuPDF's page.get_text() vs opendataloader-pdf's markdown extraction, normalized and compared with difflib.SequenceMatcher.

Two bugs in the check script itself, found and fixed before trusting any result (disclosed to the user immediately on discovery, not after):

  1. Wrong page-separator placeholder syntax ({page} instead of the tool's real %page-number%) risked silent page misalignment. Fixed by using the real placeholder and parsing the actual page number from each separator instead of assuming positional order.
  2. Python's difflib.SequenceMatcher default autojunk=True collapsed the similarity ratio to ~0.0065 for a page whose content was actually ~98% identical between tools (a long drug-name list trips its "popular element" heuristic) — a well-known stdlib gotcha. Fixed with autojunk=False.

Whole-document result (1668/1668 pages compared, mean 0.9892, median 0.9981): a tight cluster of pages — 929, 1099-1106, 1149-1153 — scored only ~0.47-0.53. Investigated instead of dismissed.

Confirmed real, serious bug in extract/spans.py: the module trusted PyMuPDF's raw block iteration order to already sequence left-column-before- right-column, validated only against one example page back in ADR 0003. Wrong on 12 of 1398 monograph-range pages (whole-range scan, e.g. physical page 1100): PyMuPDF's raw block order emits the right column before the left column there. Confirmed by rendering the page to an image and reading it directly, then confirmed in the actual assemble() output: OXYMETAZOLIN's right-column sections (Chống chỉ định, Thận trọng, Thời kỳ mang thai, Thời kỳ cho con bú, ADR, Hướng dẫn xử trí ADR, Liều lượng và cách dùng) were being silently attributed to and overwriting the still-open OXYBUTYNIN monograph's own sections, while OXYMETAZOLIN ended up missing all 7. Confirmed boundary pairs affected: OXYBUTYNIN/OXYMETAZOLIN, OXYTETRACYCLIN/OXYTOCIN, OXYTOCIN/PACLITAXEL, PIOGLITAZON/PIPECURONIUM BROMID; MAGNESI SULFAT, PILOCARPIN, and PACLITAXEL had internal (not necessarily cross-monograph) ordering corruption. This is a real, medical-content-relevant defect — wrong contraindication/ADR data silently attached to the wrong drug — not a cosmetic parsing issue.

Fixed by explicitly sorting blocks (full_width header band first, then left column, then right column, each by y-position) instead of trusting PyMuPDF's raw order. Verified: re-scanned the full 99-1496 range for the same reversed-order signature — 0 occurrences (was 12). Directly verified OXYBUTYNIN's and OXYMETAZOLIN's assemble()-produced sections are now distinct and drug-appropriate (spot-checked against the rendered page). Whole-book cli validate after the fix: unchanged at 682 monographs, 92.8% recall, 99.1% precision, 8 zero-ATC (no regression). Also tried a broader "any within-column y-order violation" scan (670 pages flagged) but verified a sample and found it's dominated by benign subscript/superscript baseline noise (e.g. "B" + subscript "6" + ")"), not real bugs — correctly discarded as evidence rather than reported as 670 new findings.

Regression test added (tests/test_extract_spans.py) using the exact real bounding boxes from physical page 1100's raw block order. 109 tests total (was 103), all passing.

Not done yet / next up:

  • The whole-document character-diff tooling itself was investigation-only (per CLAUDE.md, deleted from ingestion/scratch/ after this finding was captured here + in the regression test + in spans.py's docstring) — if this kind of check is wanted as a recurring QA step, it needs to be rebuilt as a real ingestion/validation/ module, not re-derived ad hoc each time.
  • The character-diff still has ~30-50 pages below a 0.95-0.98 similarity threshold that were not individually investigated this session (only the most extreme cluster was) — front-matter table-like pages (14-31), the back-index transition region (1498-1529), and scattered others (382, 1420, 57, 68, 309, 194, ...) remain unexplained; could be genuine table/formatting differences neither tool handles perfectly, not necessarily more instances of this same bug (the specific reversed-column signature was already whole-range-scanned to exhaustion above).
  • Phase 1.5 (golden dataset) still requires human review by design.
  • Phase 2 (chunking) has no code yet and no design decision made.

Correction to the previous entry below, per CLAUDE.md's "never fabricate" rule: re-running assemble() fresh at the start of this session (same code, nothing had changed on disk) produced 676 monographs and 48 zero-ATC-not-stated-absent, not the "680 / 46" the previous entry claimed — and that entry also self-contradicted (46 in one line, 42 two paragraphs later). Root cause: the previous session's final numbers were asserted without a fresh re-run after the very last code edit. No monographs.jsonl artifact existed to diff against, so this can't be proven beyond doubt, but it's the only explanation consistent with the evidence. Lesson applied going forward: a number is only "final" if it comes from a command run after the last related edit, in the same message reporting it.

Method used this session, per two user corrections mid-session: initial passes relied only on PyMuPDF span text and coordinate reasoning. The user first pointed out other installed PDF tools were going unused and that pages should be rendered to images and read directly rather than trusted from span dumps alone (per feedback-visual-verification) — so a first cross-check used pdfplumber.extract_text() plus rendered-page-image reads. The user then flagged this as still not matching "the strategy from before." That strategy already existed, in full, in the pdf-parsing-strategy memory and docs/adr/0003-pdf-parsing-strategy.md: 4 tools were already evaluated there (PyMuPDF, pdfplumber, opendataloader-pdf, docling), and it already concluded pdfplumber.extract_text() scrambles reading order on this document's two-column layout and must never be used for general text — only PyMuPDF (primary) and opendataloader-pdf (independent reading-order + font-metadata cross-check) are validated for that purpose. The MEMORY.md index line for that memory doesn't carry this detail, only the full memory file does — this session used the one-line index and never opened the full file before picking a cross-check tool, which is the actual process gap (not a memory-setup gap). All findings below were then re-verified with opendataloader-pdf instead, and the earlier pdfplumber pass was discarded as unreliable evidence, not cited.

Investigated and closed (whole-book cli validate against the real back-of-book index, not a sample):

  • The 8 detected monographs that didn't match any back-index entry: 2 were a real bug in validation/metrics.py (substring name-matching let a shorter monograph name, e.g. "ISOSORBID", "steal" the ground-truth match meant for a longer, textually-overlapping but genuinely distinct monograph, e.g. "ISOSORBID DINITRAT" — both are real, correctly segmented drugs). Fixed: try an exact normalized-name match before falling back to substring. The other 6 are real book-internal inconsistencies, not pipeline bugs (compound names containing " - " skipped by the already-documented cross-reference filter; title-vs-index spelling variants like "HYDROGEN PEROXID" vs the index's "Hydrogen peroxyd").
  • The 83 unmatched ground-truth entries: ~40 are back-index line-wrap parsing artifacts ("- CoA reductase" / "gonadotropin" fragments from wrapped cross-reference lines, not real entries), ~20 are front-matter/ general-chapter TOC entries (pages 39-98, before the monograph range even starts at printed page 99) that back_index.py doesn't filter out, a handful are the same title-vs-index spelling-variant pattern as above — and 7 were genuinely missing monographs, root-caused to 2 real bugs (see below) plus one real book typo (CARBAMAZEPIN's own printed heading reads "Ten chung quốc tế", missing the "ê" — confirmed independently by both a rendered-page-image read and opendataloader-pdf's text output, which shows the same missing "ê"; not fixable without risking false positives elsewhere, left as-is).

4 real bugs found and fixed, each confirmed via a whole-corpus scope check (not just the sample that surfaced it) and, where the defect could be page-rendering vs data, a rendered-page-image visual check:

  1. Same-line diacritic span-fragmentation (segment/merge.py, merge_same_line_bold_fragments, new): PyMuPDF splits some bold spans into multiple fragments around diacritic characters even when the text is one unbroken visual line — confirmed by rendering physical page 759 to an image ("Tên chung quốc tế" looks completely normal to a human reader). Cross-checked against opendataloader-pdf (the tool pdf-parsing-strategy/ADR 0003 already validated for this — not pdfplumber, which that ADR found scrambles reading order on this document's two-column layout) on 2 of the 5 affected pages (759 GUAIFENESIN, 943 MEPHENESIN): both reconstruct the line cleanly, e.g. "Tên chung quốc tế: Mephenesin. Mã ATC: M03BX06." with no fragmentation, confirming this is a PyMuPDF span-boundary artifact, not a defect in the PDF itself. Correction: an earlier version of this entry claimed all 6 candidate pages were cross-checked and listed RIBOFLAVIN among them — both wrong. Only 2 of the 5 real pages were actually re-verified with opendataloader-pdf just now, and RIBOFLAVIN's failure is the separate folio-subscript bug below, not this one — it was never part of the diacritic-fragmentation set. Broke the anchor check that gates false-positive title filtering, silently dropping whole monographs. Confirmed for 5 real monographs (GUAIFENESIN, MEPHENESIN, NATRI THIOSULFAT, RAMIPRIL, TENOXICAM) via a full 1668-page scan for the fragment signature; the other 3 (NATRI THIOSULFAT, RAMIPRIL, TENOXICAM) were not independently cross-tool-verified, only confirmed via PyMuPDF's own span coordinates (same-line y-gap).

  2. Folio-detection false conflict (extract/page_map.py, pick_folio): RIBOFLAVIN's monograph sits high enough on physical page 1243 that its own "2" subscript (from "Vitamin B₂", font size 5.83) falls inside the header band alongside the real folio "1244" (size 10.0), producing two conflicting digit candidates and silently dropping the printed page — and the whole monograph with it. Fixed by preferring the largest-font- size candidate(s) (a real folio is always set in the header's own running size, never a subscript's reduced size); a full-document scan confirmed this exact conflict shape occurs on exactly 1 of 1668 pages. Confirmed visually by rendering the page.

  3. ATC comma-inside-annotation (segment/atc.py): the field-text split on "," ran before parenthetical annotations were stripped, so an annotation containing its own comma broke the split — e.g. "Mã ATC: J07BD01 (Measles, live attenuated)." split into two unrecoverable fragments. INSULIN's earlier-fixed Vietnamese annotations ("người", "bò") never contain a comma, so this only surfaced with vaccines' English annotations — affected 12 vaccine monographs. Fixed by stripping all parenthetical groups before splitting, not just a trailing one per already-split segment. opendataloader-pdf cross-check on the real VẮC XIN SỞI page confirms the source text genuinely is "Mã ATC: J07BD01 (Measles, live attenuated)." — the bug was in parsing, not the data.

  4. ATC leading colon from the value span (segment/atc.py): some monographs render the bold label as "Mã ATC" (no colon) with the colon on the plain value span instead (": M03AA01." vs Abacavir's "J05AF06." with the colon on the label side) — the section still matched correctly, but the leftover leading colon made the stripped candidate 8 characters instead of 7, failing the length check. Fixed by stripping a leading colon in normalize_atc_candidate, symmetric with the existing trailing strip. Affected 15 monographs. opendataloader-pdf cross-check on the real ALCURONIUM CLORID page confirms clean source text ("Mã ATC: M03AA01."), same conclusion.

  5. ATC name-prefixed and reversed "CODE: Name" shapes (segment/atc.py, same session, found continuing the zero-ATC investigation after the above): two more real shapes surfaced once the first 4 fixes cleared the noise. (a) 7 monographs with multiple salt/ester forms write each form as "Name: CODE" per line, e.g. ARGININ's "Arginin glutamat: A05BA01\n Arginin hydroclorid: B05XB01" — the whole segment including the name was compared against the 7-char code shape and rejected. (b) The class-level "CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE" monograph writes it the opposite way, code first — "C10A A01: Simvastatin\nC10A A02: Lovastatin\n...". Fixed both with one change: normalize_atc_candidate now tries the text after the last ":" first, then before, returning whichever side actually normalizes to a valid ATC shape — safe because a real drug name never happens to match the strict [A-Z]\d{2}[A-Z]{2}\d{2} pattern, so there's no real ambiguity between the two candidates in practice.

Net effect, whole-book, before -> after all 5 fixes: detected monographs 676 -> 682; recall 92.2% -> 92.8% (981 -> 987 / 1064); precision 98.8% -> 99.1%; zero-ATC-not-stated-absent 48 -> 8. 103 tests total (was 88 at the start of this entry), all passing, each new fix with a regression test built from the exact real-corpus text that exposed it.

The remaining 8 zero-ATC monographs are now all explained, none left unresolved:

  • 7 (CROTAMITON, INTRALIPID, ISOSORBID, OXYBENZON, PEMIROLAST, SIMETICON, the DPT vaccine) have no "Mã ATC" section anywhere in the book at all — confirmed by reading the actual span sequence after each title (goes straight from "Tên chung quốc tế"/"Loại thuốc" to the next section, no ATC line ever appears) and by rendering physical page 845 (ISOSORBID) to an image and reading it directly. A real, accepted data gap in the source — not a parsing bug.
  • 1 (SPECTINOMYCIN) is a confirmed real book typo: its own printed heading reads "Mã ACT:" (letters transposed), not "Mã ATC:" — confirmed by rendering physical page 1297 to an image and reading it directly. Same category as CARBAMAZEPIN's "Ten chung quốc tế" typo from fix 1 above: a real defect in the source document, left unfixed rather than loosening vocabulary matching and risking new false positives elsewhere (the project's own prior "whack-a-mole" experience with over-loosened matching, per outlier-catalog item 21).

Not done yet / next up:

  • validation/back_index.py's line-wrap and front-matter-entry issues (from the investigation above) inflate the "unmatched ground truth" count but were left unfixed this session — the user's stated priority was the segmentation-pipeline bugs first, not the validation-metric's own accuracy.
  • The docs/pdf-parsing-outlier-catalog.md items for these 5 new bugs have not been added yet (the module docstrings for merge.py, page_map.py, and atc.py carry the full evidence in the meantime).
  • Only 2 of the ~7 diacritic-fragmentation pages and 2 of the ~15 leading-colon pages were independently cross-tool-verified with opendataloader-pdf (see fix 1's correction note above) — the rest rely on PyMuPDF's own span coordinates only, which is weaker evidence.
  • No exploration yet of whether the same fragmentation/folio/colon bug families affect other sections beyond "Tên chung quốc tế" and "Mã ATC" (e.g. "Chỉ định", "Liều lượng và cách dùng") — only ATC was swept whole-corpus this session.
  • Phase 1.5 (golden dataset) still requires human review by design.
  • Phase 2 (chunking) has no code yet (ingestion/chunk/ doesn't exist) and no design decision has been made on chunking strategy.

2026-07-31 — Phase 1.3-1.4 built: assembler, CLI, and validation, with 4 more real bugs found and fixed via whole-book runs

Done (continuation of the same session, user asked to keep driving autonomously via /loop; visual PDF-page rendering used throughout to self-verify bugs, per feedback-visual-verification):

  • Built assembler.py (3-pass design: classify spans -> coalesce titles -> build Monograph records), segment/io.py (JSONL read/write), cli.py (run and validate subcommands working end-to-end), and validation/back_index.py + metrics.py (recall/precision against the real back-of-book index, parsed from real physical pages 1530+).
  • Found and fixed 4 more real bugs via whole-book assemble() runs, each initially surfaced as a wrong number (never trusted the first result, per CLAUDE.md):
    1. ATC trailing-period bug: "Mã ATC: J05AF06." — the sentence-ending period was counted as part of the code, so normalize_atc_candidate silently returned zero codes for every single-ATC monograph ending in "." (a huge fraction of the corpus). Fixed by stripping trailing .,; before the length check.
    2. ATC species-annotation bug: INSULIN's real field lists all 20 codes each with a parenthetical annotation ("A10AB01 (người); ...") — only 2 of 20 survived before the fix (the two that happened to have a line-wrap between code and annotation). Fixed by stripping a trailing (...) group before normalizing. Whole-corpus multi-ATC re-count with both fixes: 159/680 (23.4%) monographs have >1 ATC code (the open item from the very first survey session, now closed with a real measured number instead of the 25.4%-floor estimate).
    3. Non-bold combined section heading (outlier item 20): AMITRIPTYLIN's "Mã ATC:" is a single non-bold span combining label and value ("Mã ATC: N06AA09."), unlike Abacavir's bold-label-only span — the book's ~700 monographs were written by many different authors, so styling isn't 100% consistent. Fixed by matching section headings by vocabulary text, not span.bold, plus a new match_section_with_inline_value for the combined-span case.
    4. Mixed-case title + false-positive whack-a-mole (outlier item 21): the class-level monograph "CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE" embeds the mixed-case abbreviation "CoA", which a strict isupper() check silently dropped from the corpus entirely. Loosening that check (first with an absolute lowercase-count tolerance, found wrong, then fixed with a lowercase-letter ratio instead — "Mã ATC:" has 1/5 = 20% lowercase, correctly still rejected, vs. HMG-CoA's 1/27 ≈ 3.7%) then exposed a second false positive: individual statin sub-headings ("SIMVASTATIN", "LOVASTATIN", ...) inside that same class monograph, each followed by their own real section but never by "Tên chung quốc tế" specifically. The anchor check (added earlier for the HSV/CMV table-header false positive, item 19) had been loosened to "any section" to pass existing tests — reverted to requiring "Tên chung quốc tế" specifically (the one invariant the book's own template actually guarantees), and fixed the test fixtures instead of the production logic.
  • Final whole-book numbers after all fixes: 680 monographs (matches the previously-established count from the original structural survey — though this is a count match, not yet a confirmed identical-set match). Abacavir ATC now correctly ["J05AF06"]; Insulin now correctly 20 codes. 46 monographs remain zero-ATC-and-not-stated-absent (down from an initial 48; not yet root-caused further — flagged, not silently accepted as final).
  • 86 unit tests total, all passing, including a regression test for every bug above and for each whack-a-mole cycle (so a future change can't silently reintroduce SIMVASTATIN-as-monograph or Mã-ATC-as-title).

Not done yet / next up:

  • The remaining 42 zero-ATC-not-absent monographs likely hide at least one more real pattern (per this session's track record of "one fix reveals the next") — worth one more investigation pass before Phase 1.5.
  • Phase 1.5 (golden dataset) still requires human review by design — not something this session can complete alone, per the approved plan.

2026-07-31 — Session end: golden dataset NOT started; general chapters + appendices NOT investigated

Status check requested by user at end of session ("golden dataset bạn để đâu?" / "đã xem chuyên luận chung và phụ lục chưa?") — answering plainly here so the next session doesn't have to guess:

  • Golden dataset (Phase 1.5): not created. ingestion/data/qa/ still contains only .gitkeep — no golden_pages.jsonl, no golden_monographs.jsonl. This is intentional, not an oversight: per the approved plan, golden-set ground truth requires human review/sign-off, which this session couldn't do alone (dynamic /loop autonomy stopped here for exactly this reason). feedback-visual-verification means a future session can self-draft much of it (render pages, read them directly) but a human still needs to spot-check before it's trustworthy.
  • "Các chuyên luận chung" (general chapters, printed pages 37-98) and "Các phụ lục" (appendices, printed pages 1497-1528): NOT investigated this session, or any prior session. All work so far (extract/segment/ validation, ADR 0003, the outlier catalog) covers only the drug-monograph range (printed 99-1496). The only contact with these two ranges was incidental: reading physical page 38-39 (inside general chapters) once to transcribe the book's own 19-field section template into segment/vocab.py, and skimming physical ~1526-1528 (inside the appendices — specifically "Phân loại thuốc theo mã ATC") only to locate where the back-of-book index begins for validation/back_index.py. Neither range has been structurally surveyed, outlier-cataloged, or parsed. This gap has been flagged since the very first scaffold session (docs/progress-log.md's original Phase 1 roadmap) and remains explicitly out of scope of the plan approved this session. Known content, not yet verified in depth: general chapters cover topics like "Kê đơn thuốc," rational antibiotic use, pediatric dosing principles; appendices include the body-surface-area nomogram table (already flagged in outlier catalog item 7 as a 2D-table extraction problem), IV-admixture compatibility info, and the ATC drug classification listing.

Next session should pick up one of:

  1. Golden dataset drafting (Phase 1.5) — scaffold from current extraction/segmentation output, self-verify via page rendering, then get human sign-off before trusting it.
  2. A first real structural investigation of general chapters + appendices (same rigor bar as the monograph range: whole-range scan, not a page or two) — needed before any chunking strategy can be designed for them.
  3. The 42 remaining zero-ATC-not-absent monographs (Phase 1.4 leftover, not blocking).

2026-07-31 — Phase 1.4 real validation run: 92.2% recall, 98.8% precision (first-ever measurement)

Done:

  • Ran python -m ingestion.cli validate for real against the full 1668-page book. First result: 91.7% recall / 98.2% precision against 1064 real back-index ground-truth entries (parsed from physical pages 1530+, not a sample) — recall matched ADR 0003's original number exactly (665/725 there was a different, smaller ground-truth set; this run's 1064 entries come from parsing the entire back index, not a partial scan), and precision was measured for the first time ever on this project, meeting the plan's ≥98% target immediately.
  • Found and fixed one more real bug from this first real run: 4 of 12 unmatched detected monographs (ALVERIN CITRAT, OXYMETAZOLIN HYDROCLORID, TERBUTALIN SULFAT, TIOTROPIUM BROMID) all shared the same shape — a double space in the detected title (e.g. "ALVERIN CITRAT") that failed to match ground truth's single-spaced "Alverin citrat" under plain strip+upper comparison. Fixed by collapsing whitespace in metrics._normalize_name before comparing.
  • Final numbers after the fix: recall 92.2% (981/1064), precision 98.8% — both real, measured, whole-book numbers, both improving over the already-fixed run (not just over the pre-session 91.7% baseline).
  • Remaining unmatched entries are traced to two already-documented, known limitations rather than new bugs: (1) back_index.py's own stated trade-off of treating any " - " as a brand-cross-reference marker also excludes genuine compound-name ground-truth entries ("Carbidopa - levodopa", vaccine names like "Vắc xin DPT" that use " - " internally), so a handful of correctly-detected monographs (CARBIDOPA - LEVODOPA, THUỐC PHIỆN - OPIAT - OPIOID, the DPT/MMR vaccine entries) simply have no matchable ground-truth counterpart, not a detection defect; (2) a repeating "- CoA reductase, 285" ground-truth artifact (appears ~12 times) is itself index-parsing noise — likely a long cross-reference line wrapping across two physical lines in a way that splits the brand name from its "- CoA reductase" continuation, which then doesn't contain the " - " marker at its own line start and slips through the cross-reference filter as a bogus ground-truth entry.
  • 87 unit tests total, all passing.

Not done yet / next up (Phase 1.5, requires human review by design — not something a single session can complete alone per the approved plan):

  • Golden dataset authoring: scaffold-golden CLI command, golden_pages/ golden_monographs JSONL schemas, human review of drafted entries.
  • The 42 remaining zero-ATC-not-absent monographs and the back_index.py compound-name/cross-reference-wrapping noise above are both flagged, not blocking — real, moderate-size gaps documented for whoever picks this up next.

2026-07-30 — Phase 1.2 segment/ pure logic built and validated against real PDF

Done (real production code, all reused by both the future CLI pipeline and validation — no logic duplicated):

  • Transcribed the book's own documented 19-field monograph template verbatim from its source (physical page 38/39 printed, "HƯỚNG DẪN SỬ DỤNG DƯỢC THƯ QUỐC GIA VIỆT NAM") into vocab.py's SECTION_DEFS, rather than guessing — cross-checked against real bold headings in the Abacavir/ Acarbose monographs (exact text match, modulo a trailing colon some pages have and others don't, now normalized). Added ten_thuong_mai ("Tên thương mại") as the confirmed 19th, undocumented-but-real field.
  • Built merge.py (multi-line/multi-fragment title merging), detector.py (monograph + section boundary detection), atc.py (3-state ATC extraction: found / recovered-from-noise / stated-absent), units.py (defensive mg/mcg/mmol validation — see below), models.py.
  • Found and fixed a second real title-fragmentation bug by rendering a page to an image and reading it directly (not just reasoning from coordinates): "ACICLOVIR" was detected as two separate titles, "ACIC" (font size 10.0) and "LOVIR" (font size 9.5) — the same visual word rendered at two slightly different sizes in the source PDF. The merge logic originally required exact font-size equality (which happened to work for the GONADOTROPIN wrap case since both its fragments are size 9.5) — dropped that requirement per the same "font size is not reliable" lesson from ADR 0003, now applied within a title's own fragments, not just across monographs. Also fixed the join character: a genuine same-line split needs no space ("ACIC"+"LOVIR"="ACICLOVIR"); a genuine multi-line wrap needs one (GONADOTROPIN case) — distinguished by the y0 gap. This same fix also resolved two other silent duplicate-name artifacts (HSV, CMV) found in the same smoke test.
  • Smoke-tested the full detector against the real PDF: 695 monograph titles detected (down from 702 pre-fix, closer to the previously-established ~680 count), part-divider correctly excluded, ABACAVIR/INSULIN present, GONADOTROPIN wrap correctly merged, zero unexplained duplicate names.
  • Investigated the one remaining duplicate name ("SALBUTAMOL", pages 1261 and 1263) by rendering both pages and reading them directly — confirmed it is NOT a bug: two genuinely different, complete monographs ("Dùng trong hô hấp" / respiratory vs. "Dùng trong sản khoa" / obstetric use), each with a full 18-section template. Added as outlier-catalog item 18 with an explicit note for Phase 1.3's assembler: drug_id generation must fold in the bold, non-all-caps qualifier line beneath the title, or it will wrongly treat this legitimate case as a duplicate-title collision.
  • Confirmed via a targeted regex scan that the units.py whitespace-split defense (built by analogy to the confirmed ATC defect) has zero confirmed real occurrences in this corpus so far — documented honestly as a defensive-only check, not a confirmed defect, per CLAUDE.md.
  • 44 unit tests total (up from 9), all passing, including regression tests for every real bug found this session (kerning jitter, column-merge, GONADOTROPIN wrap, ACICLOVIR same-line split).
  • Rendering a PDF page to an image and reading it directly (not just reasoning from PyMuPDF coordinates) turned out to be a fast, reliable way to self-verify segmentation bugs — used for both real bugs found this phase (ACICLOVIR, SALBUTAMOL) without needing a human to look at the page. This changes the Phase 1.5 golden-dataset plan: much of the ground-truth drafting can be self-verified this way before a human spot- checks it, rather than requiring a human to author it from scratch.

Not done yet / next up:

  • Phase 1.3: assembler.py (must handle the SALBUTAMOL qualifier-line case above), segment/io.py, cli.py run, wired end-to-end; smoke-test on a small page range before a full-book run.
  • Phase 1.4: validation/back_index.py + metrics.py (recall/precision against the back-of-book index), cli validate.

2026-07-30 — Phase 1.1 extract/ module built and validated against real PDF

Done (real production code, not exploratory scripts — replacing the empty ingestion/ingestion/extract/ stub per the approved segmentation + eval plan):

  • Built models.py (Span dataclass), page_map.py (physical→printed page mapping, read per-page rather than assumed as a constant — verified correct and constant at +1 across all tested milestone pages: physical 0, 36, 37, 98, 100, 1496, 1497, plus correctly returns None for blank/title pages), spans.py (continuous cross-page span stream with column tagging), io.py (JSONL persistence), and glyph_order.py (the mandatory pre-ingestion sanity gate).
  • Added pytest/pymupdf to ingestion/pyproject.toml (previously empty dependencies = []) plus [tool.setuptools.packages.find] to fix a package-discovery ambiguity that broke pip install -e . — both confirmed via a real editable install, not just added and assumed to work.
  • Corrected a real gap in ADR 0003's own validated finding: re-verifying the "reversed glyph order" defect as real tested code (not trusted from the prior exploratory script) found 2 genuine occurrences, not 1 (physical pages 714 and 1373 — two different defect shapes, see outlier catalog item 9's rewrite for full detail). Getting a trustworthy count took 3 detector iterations after the first naive whole-book run reported 1113 false positives (kerning jitter + a column-boundary false-merge bug) — full false-positive history and the fix (group by PyMuPDF's own block index, not hand-picked x-coordinates) documented in extract/glyph_order.py's docstring and the outlier catalog.
  • Smoke-tested extract_spans/build_page_map against the real PDF: 253,518 spans extracted, 30,728 bold, first monograph title (ABACAVIR) correctly located at physical page 100 / printed 101.
  • 9 unit tests added (tests/test_extract_glyph_order.py), all passing, including regression tests for the kerning-jitter and column-merge false positives found during validation (so they can't silently regress).

Not done yet / next up:

  • Phase 1.2: segment/ pure logic (vocab, merge, detector, atc, units) with unit tests reproducing every documented bug case (GONADOTROPIN wrap, part-divider false positive, ATC whitespace/O-0, "Chưa có" state) — see the approved plan (ingestion/ingestion/segment/ is still an empty stub).
  • The 3 formula-region pages (92, 94, 805) that also trip scan_reading_order should not have their "corrected" text trusted — same guidance as outlier catalog item 8 (2D formulas aren't linearly recoverable); no auto-correction should be applied to those specifically, flag-only.

2026-07-30 — Eval strategy locked in; Phase 1.0 cheap surveys run

Done (direct requirement: "phải eval thật kỹ... phải có chiến lược rõ ràng" — plan mode used to design a full segmentation + eval framework before writing any real ingestion code):

  • Designed and got user approval on a full implementation plan covering extract/ + segment/ + a validation/ package, merging the already-validated ADR 0003 methodology (back-index recall, currently 91.7%) with a 6-point eval framework the user specified (visual diff, round-trip test, character-level text coverage, structure validation, golden dataset, downstream RAG eval) plus a follow-up list of domain-safety checks (adult/child dosing not mixed, mg/mcg/mmol units not corrupted, warning/contraindication sections captured, chemical formulas, header/footer leakage, page numbers not injected mid-paragraph). Full plan is preserved for reference; key decisions below are now the standing design, not just a plan-file artifact.
  • Confirmed target audience (doctors/pharmacists, not lay users — see project_target_audience memory) explicitly informs why domain-safety checks (dosing-population mixing, unit corruption) are being treated as first-class eval dimensions, not nice-to-haves.
  • Ran Phase 1.0 whole-book surveys (scratch script, not committed):
    • Zero embedded images across all 1668 pages (get_images(full=True), measured) — image/caption validation tooling is not needed for this corpus.
    • Adult/child dosing splits are the norm, not rare: "Người lớn"/"Trẻ em"/"Trẻ sơ sinh" terms appear on 1121 of ~1400 monograph-range pages — elevates dosing-population-mixing to a standing validation check.
    • Found and confirmed a real chemical reaction equation (physical page 1033, cyanide-antidote mechanism: Na2S2O3 + CN⁻ → SCN⁻ + Na2SO3) and a new outlier: the reaction arrow extracts as a Private-Use-Area glyph (U+F0AF), not a standard Unicode arrow — added as outlier-catalog item 16. A regex scan for chemical-formula-shaped tokens found 9 raw hits, 8 of which were false positives (flu-strain names, receptor names) — genuine chemical notation exists but is rare, not systemic.
    • Attempted to pin down the exact shortest monograph name+page, but the crude (unmerged, no multi-line-title-merge) scan script produced a different longest-monograph ranking than the already-documented one (previously: "AMOXICILIN VÀ KALI CLAVULANAT" at 45,623 chars; this script's top result was INSULIN at 41,799 chars) — flagged as unreliable rather than reported as fact, and explicitly deferred to Phase 1.2's real detector rather than trusting a quick script's number over the previously-validated one. Added to outlier catalog's "not yet investigated" list with the reasoning, not silently dropped.
  • Added outlier-catalog items 15 (no images), 16 (PUA reaction-arrow glyphs), 17 (adult/child dosing prevalence).

Not done yet / next up:

  • Phase 1.1 onward: build real ingestion/ingestion/extract/ and segment/ modules (currently still empty stub packages) per the approved plan — page_map.py first, then spans.py/glyph_order.py, then the segment detector/merge/atc/units logic with unit tests, then wiring cli.py run, then the validation/ package (back-index recall+precision, golden dataset, char-coverage/structure/domain-safety checks, visual-diff). See the approved plan file for the full phase breakdown and numeric targets (≥98% monograph recall/precision, ≥99% mean character coverage, zero-regression golden-set gate, manual visual-diff sign-off on hardest pages) if this session ends before implementation completes.
  • pytest and pymupdf need to be added to ingestion/pyproject.toml dependencies (currently dependencies = []) — confirmed both are already available in the global Python 3.12.10 env (PyMuPDF 1.28.0, pytest 7.4.4) but not yet pinned in the package's own dependency list.

2026-07-30 — Whole-corpus structural survey (not just anecdotes)

Done (direct pushback: "I feel like you're minimizing how complex this PDF really is — go find another 10-30 outliers, not just Vitamin D"):

  • Built a real per-monograph structural survey across all 680 detected monographs (not 2 anecdotes) — computed ATC-code count, known-section count, and character length for every one.
  • Multi-ATC monographs are NOT rare: 173/680 (25.4%) have more than one ATC code — INSULIN has 20, BETAMETHASON and DEXAMETHASON 11 each, PREDNISOLON 10, HYDROCORTISON 9. The earlier "found 2 examples" framing badly understated this. Even 25.4% is a floor (see next point).
  • Investigated the 22 apparent "zero ATC" monographs (spot-checked 14): found two distinct real causes of false negatives — stray internal whitespace splitting an ATC code ("J04A C01" instead of "J04AC01") and digit/letter confusion ("NO3AX12" instead of "N03AX12") — 9 of 14 resolved as real ATC codes hidden by extraction noise (one of them, TRIAMCINOLON, turned out to have 5 ATC codes, meaning the true multi-ATC percentage is higher than 25.4%). The remaining ~5 genuinely say "Mã ATC: Chưa có." (not yet assigned) — a valid data state, not an error.
  • Found and confirmed a false-positive monograph boundary: the part-divider title "CÁC CHUYÊN LUẬN THUỐC" (Part 2's own section title, not a drug) was detected as if it were a monograph.
  • Measured real structural variance: monograph length ranges 2,331-45,623 characters (~20x spread), detected section count ranges 8-20.
  • All findings added to docs/pdf-parsing-outlier-catalog.md (items 12a revised with real numbers, 12c, 12d, 12e — new).
  • Verified one of my own debugging steps was itself wrong (read raw page text from the top instead of the correctly-bounded monograph segment, which briefly looked like a segmentation bug before being traced back to a debugging mistake, not a real defect) — corrected before reporting.

Not done yet / next up:

  • Full-corpus re-count with the relaxed ATC regex (whitespace-tolerant, O/0-aware) not yet run — only 14/22 zero-ATC cases spot-checked, and the 173/680 multi-ATC count still uses the strict (undercounting) regex.
  • Phase 1 real implementation still pending overall (see earlier entries).

2026-07-30 — Confirmed class-level monographs and a real source typo

Done (direct follow-up: "have you checked drug-class entries like Vitamin D, or actual spelling/font-size errors?"):

  • Found and confirmed a second real example of a class-level monograph covering multiple ATC codes/substances: "VITAMIN D VÀ CÁC THUỐC TƯƠNG TỰ" (7 ATC codes, one per specific vitamin D analogue) — same pattern as the earlier GONADOTROPIN finding, confirming this is recurring, not a one-off.
  • Found and confirmed a genuine spelling/capitalization typo in the source PDF itself: the running header on this monograph's continuation pages reads "Vitamin d..." (lowercase d) vs the correct ALL-CAPS heading "VITAMIN D...". Verified via font/bbox inspection that this is a real source-text inconsistency, not an extraction artifact. The detection heuristic still worked correctly here (the typo'd header isn't all-caps so it's correctly rejected), but this was incidental, not a designed defense against typos.
  • Added both findings to docs/pdf-parsing-outlier-catalog.md (items 12a, 12b), with the general lesson: rely on multiple independent structural signals, not any single text match, since real source typos do occur.
  • Added CLAUDE.md with a standing rule: never fabricate or bluff a claim (number, test result, capability estimate) — verify before stating, explicitly flag estimates as estimates. Grounded in concrete incidents from this investigation (the size-threshold bug, the scope-gap bug).

Not done yet / next up:

  • No systematic scan yet for other class-level (multi-ATC) monographs beyond the two found incidentally — Phase 1's data model should assume ATC code is a list per monograph regardless, rather than trying to enumerate every class-level entry in advance.
  • Phase 1 real implementation still pending overall (see earlier entries).

2026-07-30 — Comprehensive PDF outlier catalog (tables, formulas, columns)

Done (in response to direct follow-up questions about table/formula handling and full-book coverage):

  • Found and confirmed a table split across a page break loses its header on the continuation page — real example: "Bảng 4" (ARV rash management table) ends with an orphaned, header-less data row on the next page when extracted with pdfplumber.
  • Found the same header-loss risk also happens across a column boundary within a single page (no page break needed) — real example: "Bảng 6".
  • Found and confirmed 2D grid/nomogram tables are not linearly recoverable — the body-surface-area lookup table (appendix) extracts as scrambled bare numbers with no row/column association.
  • Found two different formula-rendering outcomes: a simple inline- exponent formula (Du Bois BSA) extracts cleanly as text; a stacked- fraction formula (Cockcroft-Gault) extracts as disordered fragments — confirmed the determining factor is 1D vs 2D visual layout, not "formulas are always broken."
  • Found and confirmed a full-width table that breaks out of the normal two-column page grid (bbox spans nearly the full page width).
  • Checked whether front-matter "committee list" pages are genuinely multi-column (the user suspected 3 columns) — confirmed via bbox inspection they are not true structural columns, just single wide text blocks with internal whitespace padding between names.
  • Consolidated all outlier findings from this investigation (this entry and the previous one) into a single, reusable, generalized reference: docs/pdf-parsing-outlier-catalog.md — written so it can guide parsing of other similarly-structured PDFs, not just this book.

Not done yet / next up:

  • No automatic detector exists yet for (a) 2D-formula regions, or (b) 2D grid-table reconstruction — both flagged as open items in the catalog, not silently skipped.
  • Table-continuation re-attachment (page-break and column-break cases) has no implementation yet — needed before Phase 1 can trust any multi-row table content.
  • Phase 1 real implementation still pending overall (see previous entry).

2026-07-30 — PDF parsing strategy validated empirically (pre-Phase-1)

Done:

  • Investigated the real PDF structure before writing any ingestion code (previous scaffold's assumptions about doc.get_toc() turned out wrong).
  • Confirmed: 1668 pages, no bookmark/outline (0 TOC entries), tagged-PDF structure tree exists but is too shallow to use (~29 elements only).
  • Cross-tested 3 extraction tools on real sample pages: PyMuPDF (correct reading order — kept as primary), pdfplumber (scrambled reading order on this layout — demoted to table-extraction-only use), opendataloader-pdf (correct reading order, useful independent font-metadata cross-check, but inconsistent heading classification — not trusted as sole signal). Docling install hit a numpy/pyarrow ABI conflict in the global Python env; tested in an isolated .venv_docling_test/ (gitignored) instead of risking the global environment — see whether that resolved before relying on it.
  • Found the real structural ground truth: every section/monograph heading is a bold font span in the PDF (confirmed at the PyMuPDF span level AND independently by opendataloader's own font metadata — two tools agreeing). Font size is not reliable (10.0pt and 9.5pt both occur for genuine monograph titles) — an early size-based threshold silently dropped ~15% of real monographs; caught and fixed via whole-document validation, not spot-checking.
  • Found the real ground truth for validation: the back-of-book "Mục lục tra cứu" (page ~1528 onward) has exact page numbers per drug — much stronger than the front-matter drug list (which has no page numbers). Also found the book's own contents page states individual monographs run printed pages 99-1496 exactly.
  • Ran automated whole-document (1668-page, ~20-50s per run) validation against that page-verified ground truth: 91.7% recall (665/725), with the remaining gap traced to one concrete, fixable cause (multi-line wrapped ALL-CAPS titles not yet merged across lines) rather than a flaw in the bold-span signal itself.
  • Documented the full methodology and results in docs/adr/0003-pdf-parsing-strategy.md and updated the ingestion section of docs/architecture.md to match reality (removed the incorrect TOC-preference assumption).

Also validated (in response to direct user questions about correctness):

  • No real duplicate drug monographs found across the full 1405-page monograph range. The one apparent collision ("GONADOTROPIN" at 2 pages) is a detector artifact from the known multi-line-title bug (a different monograph's wrapped title fragment collided with it), not real content duplication.
  • Confirmed the PDF is genuinely two-column (bounding-box verified: left column x≈44-299, right column x≈308-562). PyMuPDF's reading order across columns is correct (already implied by earlier validation).
  • Found and precisely characterized one real data-corruption defect: a single text run on physical page 1373 has reversed (right-to-left) glyph order, producing scrambled text — confirmed by reversing the string, which recovers the correct Vietnamese sentence. A full scan of all 1405 monograph pages (grouping fragments into visual rows, checking for descending x-order) found this exact 1 occurrence and no others — rare, isolated, but real, and now has a cheap (~16s) automated detector.
  • Full details, methodology, and exact numbers added to docs/adr/0003-pdf-parsing-strategy.md under "Follow-up validation."
  • Caught a real scope gap: the glyph-reversal scan above was initially run on the monograph range only (1405 of 1668 pages), leaving ~260 pages (front matter, appendices, back index) unchecked. Re-ran across the full 1668 pages: still exactly 1 defect (same page, 1373) — confirmed isolated, not hiding elsewhere. Also found 6 near-empty pages (3, 37, 99, 1495, 1497, 1666), all of which land exactly on major section-transition boundaries — intentional print blank pages, not lost content.

Not done yet / next up:

  • Resolve/confirm docling status in the isolated venv (numpy/pyarrow conflict was fixed by using a separate venv; install completed — actual parsing comparison against the sample pages still pending).
  • Phase 1 real implementation: build ingestion/ for real using the validated bold-span detector (not the exploratory scratch scripts) as one continuous cross-page stream (not per-page silos), fix the multi-line heading-merge gap, add the glyph-order sanity check as a mandatory pre-ingestion pass, re-run the validation script to confirm improved recall, then proceed to chunking + embedding + Qdrant upsert.
  • Decide and implement chunking strategy for the non-monograph parts of the book (general chapters pages 37-98, appendices 1497-1528) — needed so the full book (page 0 to last) ends up captured in the RAG corpus in some appropriate form, per the user's explicit requirement that no content be silently dropped.
  • Clean up exploratory scratch_* files from the repo root as they accumulate during investigation (routinely deleted after findings are persisted to docs — not left in git history).

2026-07-30 — Initial monorepo scaffold

Done:

  • Designed the microservices architecture (see docs/architecture.md): Python/FastAPI ai-service for RAG, NestJS for api-gateway/auth-service/ user-service/chat-service, Next.js web, Qdrant for vectors, Postgres for relational data, Redis reserved for caching/queues.
  • Scaffolded the full monorepo directory tree (apps/, packages/, ingestion/, infra/, docs/) with baseline config (package.json/ pyproject.toml stubs, pnpm workspace, docker-compose topology stub).
  • Moved duoc-thu-quoc-gia-viet-nam-2018.pdf into ingestion/data/raw/.
  • Decided vector DB: Qdrant over pgvector (docs/adr/0001-vector-db-qdrant.md).
  • Decided deployment: GitOps via the team's existing ArgoCD instance, not a custom push-based CD pipeline (docs/adr/0002-argocd-gitops.md, infra/argocd/). CI's job is build/test/push image + bump the Helm values image tag; ArgoCD does the actual sync.
  • git init + initial commit (this scaffold).
  • Created a private GitHub repo (BaoVu2k4/vsf-duocthu, default branch master) and pushed the initial commit; fixed targetRevision in the ArgoCD Application manifests to master to match.

Not done yet / next up (Phase 1 of the build roadmap in docs/architecture.md):

  • No business logic exists yet anywhere — this was scaffold only.
  • Phase 1: build the ingestion/ pipeline for real (PDF extraction via PyMuPDF, monograph/section segmentation, section-aware chunking, OpenAI embeddings, Qdrant upsert) and validate retrieval quality via the ingestion/notebooks/ QA step.
  • Still pending/TBD: which cloud provider (AWS/GCP/Azure) for Terraform (infra/terraform/README.md), and the team's ArgoCD instance's actual cluster/server + project details (infra/argocd/README.md TODOs).