Scaffold web frontend: mock-backed chat + PDF split-view, Tailwind/shadcn

This commit is contained in:
2026-07-31 12:16:47 +07:00
parent b72d4bf9d9
commit 967b917001
43 changed files with 5192 additions and 7 deletions
+31
View File
@@ -0,0 +1,31 @@
import json
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LOG_PATH = os.path.join(ROOT, "docs", "progress-log.md")
def latest_entry():
try:
with open(LOG_PATH, encoding="utf-8") as f:
text = f.read()
except FileNotFoundError:
return ""
for part in re.split(r"^---$", text, flags=re.MULTILINE):
if re.search(r"^## ", part, re.MULTILINE):
return part.strip()
return ""
entry = latest_entry()
if entry:
context = (
"Project: Duoc Thu RAG medical chatbot (D:\\VSF-DUOCTHU). "
"Latest entry from docs/progress-log.md (read that file and "
"CLAUDE.md for full status before assuming anything):\n\n" + entry
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": context,
}
}))
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python .claude/hooks/session_start_progress.py 2>/dev/null || true",
"statusMessage": "Loading project progress log..."
}
]
}
]
}
}
+72
View File
@@ -0,0 +1,72 @@
# Instructions for Claude working in this repo
## Never fabricate, never bluff
Do not state a number, a test result, a "verified" claim, or a capability
estimate unless it is backed by something you actually ran or actually
read. If you haven't checked something, say so explicitly ("not verified
yet", "estimate, not measured") instead of presenting a guess as fact.
**Why:** this project involves parsing a medical reference book into a
chatbot's knowledge base — false confidence here is not a cosmetic bug, it
propagates into medical answers. During the ingestion-strategy
investigation, a size-based heading threshold silently dropped ~15% of real
monographs before whole-document validation caught it; a monograph-boundary
scan was initially run on 1405 of 1668 pages before being caught and
corrected. Confident-sounding claims that turn out wrong cost real rework
and could cost real answer quality once this is live.
**How to apply:**
- Prefer "I ran X and got Y" over "X should work" — run the check.
- When asked something you don't know for certain (throughput estimates,
whether a tool/library works on this environment, whether a heuristic
holds at scale), say what's measured vs. estimated, explicitly.
- Whole-document / whole-scope validation over small-sample claims — if the
user states a total (e.g. "1668 pages"), any check must cover that literal
total before being reported as done, not a convenient subset.
- When a claim turns out wrong after fuller checking, say so plainly and
show the corrected result — don't quietly smooth over the miss.
See `docs/pdf-parsing-outlier-catalog.md` and
`docs/adr/0003-pdf-parsing-strategy.md` for the concrete track record this
rule comes from.
## Real code follows Clean Code / Clean Architecture / SoC / DRY / SOLID
Applies to anything meant to be committed as part of the actual system
(`apps/*`, `ingestion/*`, `packages/*`) — not throwaway investigation
scripts (e.g. a one-off scan to check a hypothesis), which may stay quick
and disposable as long as they're never confused for production code and
get deleted once their finding is written down.
**Why this is a written rule, not just an intention:** intentions from one
conversation don't carry into the next session, and under time pressure or
mid-refactor it's easy to let a principle slip without noticing — a written
checklist is what actually catches that, the same reasoning behind the
"never fabricate" rule above.
**How to apply, concretely, in this repo:**
- **SoC**: keep the `ingestion/` pipeline stages (`extract/`, `segment/`,
`chunk/`, `embed/`, `load/`) genuinely independent — extraction code must
not know about chunking, chunking must not call OpenAI, etc.
- **DRY**: shared logic (e.g. the bold-span heading/boundary detector) lives
in exactly one module that both the real pipeline and any validation
script import — never re-implemented per script, which is what happened
during exploratory investigation and is fine there, but must not carry
into real code.
- **SOLID**: single-responsibility modules/classes (a detector detects, it
doesn't also chunk); open/closed section taxonomy (adding a new section
label — e.g. a field like "Tên thương mại" not in the book's own
documented list — must not require editing existing matching code, only
adding an entry); dependency inversion at infrastructure boundaries
(`ai-service`'s domain/retrieval logic depends on an interface, not a
hard import of the Qdrant SDK or OpenAI client directly, so it stays
testable without live services).
- **Clean Architecture**: domain/business logic (parsing rules, chunking
rules, retrieval/grounding logic) stays independent of infrastructure
(OpenAI SDK, Qdrant client, filesystem, NestJS framework details) so it's
testable in isolation.
- **Clean Code**: meaningful names, small functions, minimal comments (only
where the *why* isn't obvious from the code itself) — matches the
no-comments-unless-non-obvious style already used throughout this
project's docs and ADRs.
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { Pill, Send } from "lucide-react";
import type { ChatMessage, Citation } from "@duoc-thu/shared-types";
import { ChatBubble, CitationCard, Card, Input, Button, cn } from "@duoc-thu/ui";
import { sendChatMessage } from "@duoc-thu/api-client";
function TypingIndicator() {
return (
<div className="inline-flex items-center gap-1 px-4 py-3" aria-label="Đang soạn câu trả lời">
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60" />
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-muted-foreground/60 [animation-delay:0.3s]" />
</div>
);
}
export interface ChatPanelProps {
onCitationClick?: (citation: Citation) => void;
className?: string;
}
export function ChatPanel({ onCitationClick, className }: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const content = input.trim();
if (!content || isSending) return;
const userMessage: ChatMessage = {
id: `local-${messages.length}`,
role: "user",
content,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsSending(true);
try {
const response = await sendChatMessage(content);
setMessages((prev) => [...prev, response.message]);
} finally {
setIsSending(false);
}
}
return (
<Card className={cn("flex w-full flex-col overflow-hidden", className)}>
<div className="flex min-h-[32rem] flex-1 flex-col gap-1 overflow-y-auto p-6">
{messages.length === 0 && (
<div className="m-auto max-w-sm text-center text-muted-foreground">
<Pill className="mx-auto mb-2 h-10 w-10 text-primary" aria-hidden="true" />
<p className="mb-1.5 text-lg font-semibold text-foreground">
Hỏi về bất kỳ loại thuốc nào
</p>
<p className="text-[0.95rem]">
dụ: &ldquo;Liều dùng paracetamol cho người lớn?&rdquo; hoặc &ldquo;Chống chỉ
đnh của amoxicillin ?&rdquo;
</p>
</div>
)}
{messages.map((message) => (
<div key={message.id}>
<ChatBubble message={message} />
{message.citations && message.citations.length > 0 && (
<div className="mb-4 mt-1.5 flex flex-wrap">
{message.citations.map((citation) => (
<CitationCard
key={citation.drugName}
citation={citation}
onClick={onCitationClick ? () => onCitationClick(citation) : undefined}
/>
))}
</div>
)}
</div>
))}
{isSending && <TypingIndicator />}
</div>
<form className="flex gap-2.5 border-t bg-muted/40 p-4" onSubmit={handleSubmit}>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Hỏi về một loại thuốc..."
disabled={isSending}
aria-label="Nhập câu hỏi"
/>
<Button type="submit" disabled={isSending}>
<Send className="h-4 w-4" aria-hidden="true" />
{isSending ? "Đang gửi" : "Gửi"}
</Button>
</form>
</Card>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@duoc-thu/ui";
const TABS = [
{ href: "/", label: "Trò chuyện" },
{ href: "/tra-cuu", label: "Tra cứu cùng PDF" },
];
export function NavTabs() {
const pathname = usePathname();
return (
<nav className="flex gap-1" aria-label="Chuyển chế độ">
{TABS.map((tab) => {
const isActive = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors",
isActive
? "bg-white/20 text-primary-foreground"
: "text-primary-foreground/70 hover:bg-white/10 hover:text-primary-foreground"
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { readFile } from "fs/promises";
import path from "path";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const PDF_PATH = path.join(
process.cwd(),
"..",
"..",
"ingestion",
"data",
"raw",
"duoc-thu-quoc-gia-viet-nam-2018.pdf"
);
export async function GET() {
try {
const file = await readFile(PDF_PATH);
return new NextResponse(file, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "inline",
},
});
} catch {
return NextResponse.json(
{
error:
"Không tìm thấy file PDF nguồn tại ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf.",
},
{ status: 404 }
);
}
}
+41
View File
@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 40 30% 97%;
--foreground: 175 30% 12%;
--card: 0 0% 100%;
--card-foreground: 175 30% 12%;
--primary: 173 62% 40%;
--primary-foreground: 160 60% 98%;
--secondary: 165 30% 94%;
--secondary-foreground: 175 30% 12%;
--muted: 60 20% 95%;
--muted-foreground: 175 12% 42%;
--accent: 165 35% 92%;
--accent-foreground: 175 30% 12%;
--border: 60 15% 89%;
--input: 60 15% 89%;
--ring: 173 62% 40%;
--warning: 48 96% 89%;
--warning-foreground: 22 78% 26%;
--radius: 1rem;
}
}
@layer base {
* {
@apply border-border;
}
html {
font-size: 18px;
}
body {
@apply bg-background text-foreground;
line-height: 1.6;
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { DisclaimerBanner } from "@duoc-thu/ui";
import { NavTabs } from "./_components/NavTabs";
import "./globals.css";
export const metadata: Metadata = {
title: "Dược Thư RAG",
description: "Chatbot tra cứu Dược thư quốc gia Việt Nam",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi">
<body className="flex min-h-screen flex-col">
<DisclaimerBanner />
<header className="flex flex-wrap items-center gap-4 bg-gradient-to-r from-primary to-teal-900 px-6 py-4 text-primary-foreground shadow-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/15">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</div>
<div>
<p className="m-0 text-lg font-bold leading-tight">Dược Thư RAG</p>
<p className="m-0 text-sm leading-tight text-primary-foreground/85">
Tra cứu Dược thư quốc gia Việt Nam
</p>
</div>
</div>
<NavTabs />
</header>
<main className="flex flex-1 justify-center p-6">{children}</main>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { ChatPanel } from "./_components/ChatPanel";
export default function ChatPage() {
return <ChatPanel className="max-w-2xl" />;
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import type { Citation } from "@duoc-thu/shared-types";
import { ChatPanel } from "../_components/ChatPanel";
export default function TraCuuPage() {
const [pdfSrc, setPdfSrc] = useState("/api/pdf");
function handleCitationClick(citation: Citation) {
const [page] = citation.sourcePageRange;
setPdfSrc(`/api/pdf#page=${page}`);
}
return (
<div className="flex w-full max-w-7xl flex-col gap-4 lg:flex-row lg:items-stretch">
<div className="min-h-[32rem] flex-[1.2] overflow-hidden rounded-2xl border bg-card shadow-sm">
<iframe
key={pdfSrc}
src={pdfSrc}
title="Dược thư quốc gia Việt Nam 2018"
className="h-full min-h-[32rem] w-full"
/>
</div>
<ChatPanel className="flex-1" onCitationClick={handleCitationClick} />
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "../../packages/ui/src/primitives",
"utils": "../../packages/ui/src/lib/utils",
"ui": "../../packages/ui/src/primitives"
}
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@duoc-thu/shared-types", "@duoc-thu/ui", "@duoc-thu/api-client"],
};
module.exports = nextConfig;
+32 -1
View File
@@ -1,5 +1,36 @@
{
"name": "@duoc-thu/web",
"private": true,
"version": "0.0.0"
"version": "0.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@duoc-thu/api-client": "workspace:*",
"@duoc-thu/shared-types": "workspace:*",
"@duoc-thu/ui": "workspace:*",
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.400.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.5.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+65
View File
@@ -0,0 +1,65 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./app/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "1.5rem",
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"bounce-dot": {
"0%, 60%, 100%": { transform: "translateY(0)", opacity: "0.5" },
"30%": { transform: "translateY(-0.25rem)", opacity: "1" },
},
},
animation: {
"bounce-dot": "bounce-dot 1.2s infinite ease-in-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+141
View File
@@ -239,6 +239,147 @@ treating each line independently.
will have this exact failure mode; always merge candidate multi-line
headings before using them as unique keys.
### 12a. Class-level monographs cover multiple active ingredients (multiple ATC codes) — this is NOT rare
**What it looks like:** first noticed via two incidental examples
("GONADOTROPIN", "VITAMIN D VÀ CÁC THUỐC TƯƠNG TỰ"), then actually measured
across the whole 680-monograph corpus (not assumed from the 2 examples —
this distinction matters, see below). **Real, whole-corpus number: 173 of
680 detected monographs (25.4%) have more than one distinct ATC code**,
ranging up to extreme cases — INSULIN alone lists **20** different ATC
codes, BETAMETHASON and DEXAMETHASON 11 each, PREDNISOLON 10,
HYDROCORTISON 9. This is a quarter of the entire corpus, not a couple of
edge cases — the 2 incidental examples badly understated how common this
is, and stating "found 2 examples, pattern confirmed" without the
whole-corpus count would have been exactly the kind of unverified claim
this project's CLAUDE.md now forbids.
**Even the 25.4% is a floor, not the true number** — see item 12c below:
ATC-code text-extraction noise (stray whitespace, O/0 confusion) caused
some genuinely multi-ATC monographs (e.g. "TRIAMCINOLON", 5 codes) to be
undercounted by a naive regex. The true proportion is measurably higher
than 25.4%; re-measure after fixing the regex, don't keep citing 25.4% as
final.
**Why it matters:** a data model that assumes "one monograph = one drug =
one ATC code" is wrong for roughly a quarter or more of the corpus.
**Handling:** store ATC code (and dosage-form sub-entries) as a **list**
per monograph, not a scalar; when chunking, consider whether a
class-level monograph's sections should be tagged with the whole class
name, the specific sub-compound, or both, depending on what the retrieval
use case needs.
**Generalizes:** yes — any reference work organized primarily by drug
class or by generic substance will have entries that don't map 1:1 to a
single identifier. More importantly, the *methodology* generalizes: when
you notice a pattern from 1-2 examples, measure its real prevalence across
the whole corpus before deciding how much engineering effort it deserves —
"found 2 examples" and "25.4% of everything" call for very different
levels of investment, and you can't tell which one you're dealing with
without the whole-corpus count.
### 12c. ATC codes (and likely other structured codes) have real text-extraction noise
**What it looks like:** while investigating why 22/680 (3.2%) monographs
appeared to have zero ATC codes, spot-checked 14 of them directly and found
**two distinct, confirmed causes**, both text-extraction noise rather than
missing content:
- **Stray internal whitespace** splitting one code into two tokens, e.g.
`"L01X X02"` (should be `L01XX02`), `"J04A C01"` (should be `J04AC01`),
`"N05B A06"` (should be `N05BA06`).
- **Digit/letter confusion**: a literal "0" rendered/typeset as the letter
"O", e.g. `"NO3AX12"` (should be `N03AX12`), `"JO1DC07"` (should be
`J01DC07`).
A relaxed regex tolerating both patterns resolved **9 of the 14** spot-checked
cases as real ATC codes hiding behind extraction noise. The **remaining
~5 of 14** were genuinely different: the source text explicitly states
`"Mã ATC: Chưa có."` or `"Mã ATC: Không có."` ("not yet available" / "none")
— a real, valid data state, not an error, and not something to paper over
as if a code exists.
**Why it matters:** a strict ATC-code regex silently undercounts real ATC
data; distinguishing "extraction noise hiding a real code" from "the book
says there is no code" requires checking the actual field text, not just
whether a regex matched.
**Handling:** normalize ATC-code-shaped text before matching (strip internal
whitespace between the letter/digit groups, treat a digit-position "O" as
"0") and explicitly check for the "Chưa có"/"Không có" literal strings as a
valid "no ATC" state rather than a parse failure.
**Generalizes:** yes — any structured code/identifier extracted from a PDF
(product codes, classification codes, reference numbers) can suffer this
same whitespace-injection and O/0 confusion; validate structured-looking
fields against their expected format and investigate exceptions rather than
assuming a strict pattern match is reliable.
### 12d. A section-title (part-divider) page can be falsely detected as a monograph
**What it looks like:** confirmed — the very first item in a whole-corpus
boundary scan was "CÁC CHUYÊN LUẬN THUỐC" (the literal title of Part 2 of
the book, "The Drug Monographs" — a part-divider heading, not a drug) at
physical page 98, picked up as a false-positive monograph boundary because
it happened to be bold, all-caps, short, and was followed (a few real
monograph-boundaries later) by some "Tên chung quốc tế" text from the
actual first real monograph.
**Why it matters:** without a whole-corpus scan this would have gone
unnoticed indefinitely — it doesn't look wrong from a single-page read of
Abacavir, and the discovery methodology this catalog is built on is
exhaustive scans, so this is a good example of a defect that only surfaces
at full scale.
**Handling:** exclude a small, known set of non-drug part/section-divider
strings ("CÁC CHUYÊN LUẬN THUỐC", "CÁC CHUYÊN LUẬN CHUNG", "CÁC PHỤ LỤC",
etc. — enumerable from the book's own table of contents) from the
monograph-boundary detector, or require the anchor phrase ("Tên chung quốc
tế") within a tighter line-distance so an unrelated real monograph several
lines away doesn't false-confirm a divider title.
**Generalizes:** yes — any document with part/section-divider title pages
styled similarly to its content headings (bold, prominent, short) risks
this exact false positive; explicitly exclude known structural/navigational
titles from content-boundary detectors.
### 12b. Genuine spelling/capitalization typos exist in the source text
**What it looks like:** confirmed real example — the running header on the
Vitamin D monograph's continuation pages reads `"Vitamin d và các thuốc
tương tự"` (lowercase "d"), while the real ALL-CAPS heading correctly reads
`"VITAMIN D VÀ CÁC THUỐC TƯƠNG TỰ"`. This is a genuine typesetting mistake
in the 2018 print, confirmed via font/bbox inspection (same bold 10pt font
as the correct heading — not an extraction artifact, the source text itself
has the typo). The page's bottom running *footer* uses yet another variant,
the short form `"Vitamin D"` (correctly capitalized) — meaning the same
monograph has **three different boilerplate text variants** across one
page (top header with a typo, the real heading, bottom footer).
**Why it matters:** don't treat running headers/footers as a perfectly
clean, typo-free secondary signal (item 13 in this catalog already
recommends using them as a cross-check) — they can themselves contain
source-level errors. In this specific case, the detection heuristic
(strict ALL-CAPS requirement, item 10) happened to still work correctly,
because "Vitamin d và các thuốc tương tự" and "Vitamin D" are not fully
uppercase and so are correctly rejected as monograph-boundary candidates —
but this was not a designed defense against typos specifically, just a
side effect of the all-caps requirement. A future/different typo (e.g. an
accidentally all-caps running header) would not be caught the same way.
**Check:** no systematic typo-detection was built (out of scope — this is
about parsing robustness, not proofreading the source); the practical
takeaway is to keep relying on the strict structural signals (bold + all
caps + short + anchor phrase) as primary, and treat any single text-based
signal (including running headers) as fallible.
**Generalizes:** yes — any real-world print-to-PDF source will have some
rate of genuine typos/inconsistencies; parsing logic should be robust to
them by relying on multiple independent structural signals (font,
position, anchor phrases) rather than trusting any single text match to be
error-free.
### 12e. Monograph length and section coverage vary enormously — measured, not assumed
**What it looks like:** across all 680 detected monographs, length ranges
from **2,331 to 45,623 characters** (~20x spread) and the number of known
section labels found per monograph ranges from as few as **8** up to
**20** (out of a ~19-20 item known vocabulary) — most cluster around
16-19, but the tails are real: "ASPARAGINASE"-adjacent short entries around
2,300-4,300 chars vs. "AMOXICILIN VÀ KALI CLAVULANAT" at 45,623 chars.
**Why it matters:** don't design chunking limits (e.g. a fixed max tokens
per monograph, or an assumption that "a monograph roughly fits in N
chunks") around a single example — the real distribution has a long tail
on both ends.
**Check:** this came from the same whole-corpus survey used for items 12a
and 12c — computing length and detected-section-count per monograph is
cheap and worth keeping as a standing sanity metric (e.g. flag any
monograph outside some percentile range for manual review).
**Generalizes:** yes — any corpus of "similar" documents (monographs,
product entries, articles) will have a real length/completeness
distribution; measure it before assuming uniformity.
### 12. The documented taxonomy is not exhaustive — keep it open
**What it looks like:** the book explicitly documents a 19-field template
for every drug monograph (page 38), but real monographs contain at least
+73
View File
@@ -13,6 +13,79 @@ end if that risk is showing.
---
## 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
+8 -1
View File
@@ -2,5 +2,12 @@
"name": "@duoc-thu/api-client",
"private": true,
"version": "0.0.0",
"main": "src/index.ts"
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@duoc-thu/shared-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
+1 -1
View File
@@ -1 +1 @@
export {};
export * from "./sendChatMessage";
+26
View File
@@ -0,0 +1,26 @@
import type { SendMessageResponse } from "@duoc-thu/shared-types";
let nextId = 1;
export function buildMockResponse(userContent: string): SendMessageResponse {
const id = String(nextId++);
return {
message: {
id,
role: "assistant",
content:
`(Mock) Paracetamol được chỉ định để giảm đau, hạ sốt. Liều thường dùng ở người ` +
`lớn là 500-1000mg mỗi 4-6 giờ, tối đa 4g/ngày. Đây là dữ liệu giả lập cho câu hỏi: "${userContent}".`,
citations: [
{
drugName: "PARACETAMOL",
sectionType: "lieu_dung",
sourcePageRange: [412, 413],
},
],
disclaimer:
"Câu trả lời chỉ mang tính tham khảo, không thay thế chỉ định của bác sĩ hoặc dược sĩ.",
createdAt: new Date().toISOString(),
},
};
}
@@ -0,0 +1,9 @@
import type { SendMessageResponse } from "@duoc-thu/shared-types";
import { buildMockResponse } from "./mockFixtures";
const MOCK_LATENCY_MS = 400;
export async function sendChatMessage(content: string): Promise<SendMessageResponse> {
await new Promise((resolve) => setTimeout(resolve, MOCK_LATENCY_MS));
return buildMockResponse(content);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../config/tsconfig-base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+5 -1
View File
@@ -2,5 +2,9 @@
"name": "@duoc-thu/shared-types",
"private": true,
"version": "0.0.0",
"main": "src/index.ts"
"main": "src/index.ts",
"types": "src/index.ts",
"devDependencies": {
"typescript": "^5.5.0"
}
}
+18
View File
@@ -0,0 +1,18 @@
export interface Citation {
drugName: string;
sectionType: string;
sourcePageRange: [number, number];
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
citations?: Citation[];
disclaimer?: string;
createdAt: string;
}
export interface SendMessageResponse {
message: ChatMessage;
}
+1 -1
View File
@@ -1 +1 @@
export {};
export * from "./dto/chat";
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../config/tsconfig-base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+17 -1
View File
@@ -2,5 +2,21 @@
"name": "@duoc-thu/ui",
"private": true,
"version": "0.0.0",
"main": "src/index.ts"
"main": "src/index.ts",
"types": "src/index.ts",
"peerDependencies": {
"react": "^18.3.0"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.400.0",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@duoc-thu/shared-types": "workspace:*",
"@types/react": "^18.3.0",
"typescript": "^5.5.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { ChatMessage } from "@duoc-thu/shared-types";
import { cn } from "./lib/utils";
export interface ChatBubbleProps {
message: ChatMessage;
}
export function ChatBubble({ message }: ChatBubbleProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"max-w-lg rounded-2xl px-4 py-3 text-[1.02rem] leading-relaxed shadow-sm",
isUser
? "ml-auto rounded-br-sm bg-primary text-primary-foreground"
: "mr-auto rounded-bl-sm bg-muted text-foreground"
)}
>
<p className="m-0 whitespace-pre-wrap">{message.content}</p>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { FileText } from "lucide-react";
import type { Citation } from "@duoc-thu/shared-types";
import { badgeVariants } from "./primitives/badge";
import { cn } from "./lib/utils";
export interface CitationCardProps {
citation: Citation;
onClick?: () => void;
}
export function CitationCard({ citation, onClick }: CitationCardProps) {
const [fromPage, toPage] = citation.sourcePageRange;
const className = cn(
badgeVariants({ variant: "outline" }),
"mr-1.5 mt-1 border-primary/20 bg-primary/5 font-normal text-foreground",
onClick && "cursor-pointer transition-colors hover:bg-primary/10"
);
const content = (
<>
<FileText className="h-3.5 w-3.5 text-primary" aria-hidden="true" />
<span className="font-bold text-primary">{citation.drugName}</span>
<span className="text-muted-foreground">{citation.sectionType}</span>
<span className="text-muted-foreground">
tr. {fromPage}
{toPage !== fromPage ? `${toPage}` : ""}
</span>
</>
);
if (onClick) {
return (
<button type="button" className={className} onClick={onClick}>
{content}
</button>
);
}
return <div className={className}>{content}</div>;
}
+28
View File
@@ -0,0 +1,28 @@
import { TriangleAlert } from "lucide-react";
import { Alert, AlertDescription } from "./primitives/alert";
import { cn } from "./lib/utils";
const DEFAULT_TEXT =
"Nội dung trả lời được tổng hợp từ Dược thư quốc gia Việt Nam và chỉ mang tính tham khảo, " +
"không thay thế chỉ định của bác sĩ hoặc dược sĩ.";
export interface DisclaimerBannerProps {
text?: string;
className?: string;
}
export function DisclaimerBanner({ text = DEFAULT_TEXT, className }: DisclaimerBannerProps) {
return (
<Alert
variant="warning"
className={cn(
"flex items-center justify-center gap-2 rounded-none border-x-0 border-t-0 py-2.5 text-center",
"[&>svg]:static [&>svg]:left-auto [&>svg]:top-auto [&>svg~*]:pl-0",
className
)}
>
<TriangleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<AlertDescription>{text}</AlertDescription>
</Alert>
);
}
+9 -1
View File
@@ -1 +1,9 @@
export {};
export * from "./ChatBubble";
export * from "./CitationCard";
export * from "./DisclaimerBanner";
export * from "./primitives/button";
export * from "./primitives/card";
export * from "./primitives/input";
export * from "./primitives/alert";
export * from "./primitives/badge";
export * from "./lib/utils";
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-3.5 [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
warning: "border-warning/40 bg-warning text-warning-foreground [&>svg]:text-warning-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("leading-relaxed", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertDescription };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "border-border bg-accent text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-11 px-5 py-2",
sm: "h-9 px-3",
lg: "h-12 px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import { cn } from "../lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardContent, CardFooter };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "../lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-md border border-input bg-background px-4 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../config/tsconfig-base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+4043
View File
File diff suppressed because it is too large Load Diff