Scaffold web frontend: mock-backed chat + PDF split-view, Tailwind/shadcn
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -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..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -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]">
|
||||||
|
Ví dụ: “Liều dùng paracetamol cho người lớn?” hoặc “Chống chỉ
|
||||||
|
định của amoxicillin là gì?”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChatPanel } from "./_components/ChatPanel";
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
return <ChatPanel className="max-w-2xl" />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
@@ -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.
|
||||||
@@ -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
@@ -1,5 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "@duoc-thu/web",
|
"name": "@duoc-thu/web",
|
||||||
"private": true,
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -239,6 +239,147 @@ treating each line independently.
|
|||||||
will have this exact failure mode; always merge candidate multi-line
|
will have this exact failure mode; always merge candidate multi-line
|
||||||
headings before using them as unique keys.
|
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
|
### 12. The documented taxonomy is not exhaustive — keep it open
|
||||||
**What it looks like:** the book explicitly documents a 19-field template
|
**What it looks like:** the book explicitly documents a 19-field template
|
||||||
for every drug monograph (page 38), but real monographs contain at least
|
for every drug monograph (page 38), but real monographs contain at least
|
||||||
|
|||||||
@@ -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)
|
## 2026-07-30 — Comprehensive PDF outlier catalog (tables, formulas, columns)
|
||||||
|
|
||||||
**Done (in response to direct follow-up questions about table/formula
|
**Done (in response to direct follow-up questions about table/formula
|
||||||
|
|||||||
@@ -2,5 +2,12 @@
|
|||||||
"name": "@duoc-thu/api-client",
|
"name": "@duoc-thu/api-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"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 @@
|
|||||||
export {};
|
export * from "./sendChatMessage";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../config/tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
"name": "@duoc-thu/shared-types",
|
"name": "@duoc-thu/shared-types",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "src/index.ts"
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
export {};
|
export * from "./dto/chat";
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../config/tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -2,5 +2,21 @@
|
|||||||
"name": "@duoc-thu/ui",
|
"name": "@duoc-thu/ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../config/tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+4043
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user