Add doc-service tests, AI category suggestions, LM Studio default
- pytest suite for doc-service: 20+ tests covering category CRUD, document upload/get/delete/patch, ownership isolation, category assignment, AI processing (mock), and live PDF tests (auto-skipped when tests/pdfs/ is empty) - Minimal in-memory PDF builder in conftest so tests run without any fixture files; real PDFs can be dropped into tests/pdfs/ to activate live extraction tests - AI prompt updated to return suggested_categories (2–5 short names) - Frontend: SuggestionChip component in DocumentRow shows AI-suggested categories after processing; "Assign" links to an existing category, "Create & Assign" creates it first, ✕ dismisses locally - Default AI provider changed to LM Studio at http://host.docker.internal:1234/v1 (host.docker.internal resolves to the macOS host from inside Docker Desktop) - tests/pdfs/ directory tracked via .gitkeep; *.pdf excluded by .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,57 @@ function StatusBadge({ status }: { status: DocumentOut["status"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Category suggestions ────────────────────────────────────────────────────
|
||||
|
||||
interface SuggestionChipProps {
|
||||
name: string;
|
||||
existing: CategoryOut | undefined;
|
||||
onAccept: (name: string, existing: CategoryOut | undefined) => void;
|
||||
onDismiss: (name: string) => void;
|
||||
}
|
||||
|
||||
function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipProps) {
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
fontSize: 12,
|
||||
background: "#fff8e1",
|
||||
border: "1px solid #f0c040",
|
||||
borderRadius: 4,
|
||||
padding: "3px 8px",
|
||||
marginRight: 6,
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
<span>{name}</span>
|
||||
<button
|
||||
onClick={() => onAccept(name, existing)}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
padding: "1px 5px",
|
||||
background: existing ? "#dce8ff" : "#e8f5e9",
|
||||
border: "1px solid #aaa",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
title={existing ? `Assign existing category "${name}"` : `Create and assign "${name}"`}
|
||||
>
|
||||
{existing ? "Assign" : "Create & Assign"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDismiss(name)}
|
||||
style={{ fontSize: 10, cursor: "pointer", background: "none", border: "none", color: "#999" }}
|
||||
title="Dismiss suggestion"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Document row ────────────────────────────────────────────────────────────
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
categories,
|
||||
@@ -48,11 +99,36 @@ function DocumentRow({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Parse extracted_data once
|
||||
let extractedData: Record<string, unknown> | null = null;
|
||||
if (doc.extracted_data) {
|
||||
try { extractedData = JSON.parse(doc.extracted_data); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const tags: string[] = [];
|
||||
if (doc.tags) {
|
||||
try {
|
||||
const parsed = JSON.parse(doc.tags);
|
||||
if (Array.isArray(parsed)) tags.push(...parsed);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Suggested categories from AI — dismissed ones are tracked locally
|
||||
const allSuggestions: string[] = Array.isArray(extractedData?.suggested_categories)
|
||||
? (extractedData!.suggested_categories as string[])
|
||||
: [];
|
||||
const assignedNames = new Set(doc.categories.map((c) => c.name));
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
|
||||
// Only show suggestions that haven't been assigned yet and haven't been dismissed
|
||||
const pendingSuggestions = allSuggestions.filter(
|
||||
(s) => !assignedNames.has(s) && !dismissed.has(s)
|
||||
);
|
||||
|
||||
// Poll status while pending/processing
|
||||
const { data: liveStatus } = useQuery({
|
||||
queryKey: ["docStatus", doc.id],
|
||||
queryFn: () => getDocumentStatus(doc.id),
|
||||
// v5: refetchInterval receives the Query object; data lives in query.state.data
|
||||
refetchInterval: (query) => {
|
||||
const s = query.state.data?.status;
|
||||
return s === "pending" || s === "processing" ? 3000 : false;
|
||||
@@ -76,38 +152,36 @@ function DocumentRow({
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }),
|
||||
});
|
||||
|
||||
const createAndAssignMut = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const cat = await createCategory(name);
|
||||
await assignCategory(doc.id, cat.id);
|
||||
return cat;
|
||||
},
|
||||
onSuccess: (_cat, name) => {
|
||||
setDismissed((prev) => new Set([...prev, name]));
|
||||
qc.invalidateQueries({ queryKey: ["documents"] });
|
||||
qc.invalidateQueries({ queryKey: ["categories"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAcceptSuggestion = (name: string, existing: CategoryOut | undefined) => {
|
||||
if (existing) {
|
||||
assignMut.mutate({ catId: existing.id });
|
||||
setDismissed((prev) => new Set([...prev, name]));
|
||||
} else {
|
||||
createAndAssignMut.mutate(name);
|
||||
}
|
||||
};
|
||||
|
||||
const assignedIds = new Set(doc.categories.map((c) => c.id));
|
||||
const unassigned = categories.filter((c) => !assignedIds.has(c.id));
|
||||
|
||||
let extractedData: Record<string, unknown> | null = null;
|
||||
if (doc.extracted_data) {
|
||||
try {
|
||||
extractedData = JSON.parse(doc.extracted_data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const tags: string[] = [];
|
||||
if (doc.tags) {
|
||||
try {
|
||||
const parsed = JSON.parse(doc.tags);
|
||||
if (Array.isArray(parsed)) tags.push(...parsed);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: "1px solid #ddd", borderRadius: 6, marginBottom: 12 }}>
|
||||
{/* Row header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 16px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 16px", cursor: "pointer" }}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{doc.filename}</span>
|
||||
@@ -119,10 +193,7 @@ function DocumentRow({
|
||||
{(doc.file_size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadDocument(doc.id, doc.filename);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); downloadDocument(doc.id, doc.filename); }}
|
||||
style={{ fontSize: 12, cursor: "pointer" }}
|
||||
>
|
||||
Download
|
||||
@@ -138,46 +209,37 @@ function DocumentRow({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}>
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<strong>Tags:</strong>{" "}
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: "#eee",
|
||||
borderRadius: 3,
|
||||
padding: "2px 6px",
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<span key={t} style={{ fontSize: 12, background: "#eee", borderRadius: 3, padding: "2px 6px", marginRight: 4 }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extracted fields (excluding internal-only keys) */}
|
||||
{extractedData && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<strong>Extracted data:</strong>
|
||||
<table style={{ marginTop: 6, fontSize: 13, borderCollapse: "collapse" }}>
|
||||
<tbody>
|
||||
{Object.entries(extractedData)
|
||||
.filter(([k]) => k !== "tags")
|
||||
.filter(([k]) => k !== "tags" && k !== "suggested_categories")
|
||||
.map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td style={{ paddingRight: 16, color: "#666", verticalAlign: "top" }}>{k}</td>
|
||||
<td>
|
||||
{Array.isArray(v)
|
||||
? v.length === 0
|
||||
? "—"
|
||||
: JSON.stringify(v, null, 2)
|
||||
: v !== null && v !== undefined && v !== ""
|
||||
? String(v)
|
||||
: "—"}
|
||||
? v.length === 0 ? "—" : JSON.stringify(v, null, 2)
|
||||
: v !== null && v !== undefined && v !== "" ? String(v) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -186,25 +248,18 @@ function DocumentRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{doc.error_message && (
|
||||
<div style={{ marginTop: 10, color: "#c00", fontSize: 13 }}>
|
||||
Error: {doc.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned categories */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<strong style={{ fontSize: 13 }}>Categories:</strong>{" "}
|
||||
{doc.categories.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: "#dce8ff",
|
||||
borderRadius: 3,
|
||||
padding: "2px 6px",
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
<span key={c.id} style={{ fontSize: 12, background: "#dce8ff", borderRadius: 3, padding: "2px 6px", marginRight: 4 }}>
|
||||
{c.name}{" "}
|
||||
<button
|
||||
onClick={() => removeCatMut.mutate({ catId: c.id })}
|
||||
@@ -230,12 +285,40 @@ function DocumentRow({
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI-suggested categories */}
|
||||
{pendingSuggestions.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<strong style={{ fontSize: 13 }}>Suggested by AI:</strong>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{pendingSuggestions.map((name) => {
|
||||
const existing = categories.find(
|
||||
(c) => c.name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return (
|
||||
<SuggestionChip
|
||||
key={name}
|
||||
name={name}
|
||||
existing={existing}
|
||||
onAccept={handleAcceptSuggestion}
|
||||
onDismiss={(n) => setDismissed((prev) => new Set([...prev, n]))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "#999", margin: "4px 0 0" }}>
|
||||
"Assign" links an existing category · "Create & Assign" creates it first · ✕ dismisses the suggestion
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const qc = useQueryClient();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -317,15 +400,7 @@ export default function DocumentsPage() {
|
||||
<summary style={{ cursor: "pointer", fontWeight: 500 }}>Manage categories</summary>
|
||||
<div style={{ marginTop: 10, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{categories.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
background: "#eee",
|
||||
borderRadius: 4,
|
||||
padding: "4px 10px",
|
||||
}}
|
||||
>
|
||||
<span key={c.id} style={{ fontSize: 13, background: "#eee", borderRadius: 4, padding: "4px 10px" }}>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user