diff --git a/.claude/hooks/session_start_progress.py b/.claude/hooks/session_start_progress.py new file mode 100644 index 0000000..6fc727f --- /dev/null +++ b/.claude/hooks/session_start_progress.py @@ -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, + } + })) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..db9c8f1 --- /dev/null +++ b/.claude/settings.json @@ -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..." + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3dfb061 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/web/app/_components/ChatPanel.tsx b/apps/web/app/_components/ChatPanel.tsx new file mode 100644 index 0000000..0fc8346 --- /dev/null +++ b/apps/web/app/_components/ChatPanel.tsx @@ -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 ( +
+ + + +
+ ); +} + +export interface ChatPanelProps { + onCitationClick?: (citation: Citation) => void; + className?: string; +} + +export function ChatPanel({ onCitationClick, className }: ChatPanelProps) { + const [messages, setMessages] = useState([]); + 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 ( + +
+ {messages.length === 0 && ( +
+
+ )} + {messages.map((message) => ( +
+ + {message.citations && message.citations.length > 0 && ( +
+ {message.citations.map((citation) => ( + onCitationClick(citation) : undefined} + /> + ))} +
+ )} +
+ ))} + {isSending && } +
+
+ setInput(event.target.value)} + placeholder="Hỏi về một loại thuốc..." + disabled={isSending} + aria-label="Nhập câu hỏi" + /> + +
+
+ ); +} diff --git a/apps/web/app/_components/NavTabs.tsx b/apps/web/app/_components/NavTabs.tsx new file mode 100644 index 0000000..bebbe52 --- /dev/null +++ b/apps/web/app/_components/NavTabs.tsx @@ -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 ( + + ); +} diff --git a/apps/web/app/api/pdf/route.ts b/apps/web/app/api/pdf/route.ts new file mode 100644 index 0000000..bd61c14 --- /dev/null +++ b/apps/web/app/api/pdf/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..b9b2afc --- /dev/null +++ b/apps/web/app/globals.css @@ -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; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..c4bb3f1 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -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 ( + + + +
+
+
+ +
+
+

Dược Thư RAG

+

+ Tra cứu Dược thư quốc gia Việt Nam +

+
+
+ +
+
{children}
+ + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..6561682 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { ChatPanel } from "./_components/ChatPanel"; + +export default function ChatPage() { + return ; +} diff --git a/apps/web/app/tra-cuu/page.tsx b/apps/web/app/tra-cuu/page.tsx new file mode 100644 index 0000000..790699d --- /dev/null +++ b/apps/web/app/tra-cuu/page.tsx @@ -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 ( +
+
+