Add AI-suggested editable document title

AI now returns a short descriptive title per document (e.g. "ACME Corp
Invoice April 2026"). Title is stored in a new documents.title column
(migration 0002), shown in the row header instead of the raw filename,
and editable inline via PATCH /documents/{id}/title. Filename is shown
as a subtitle when a title exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-14 16:26:18 +02:00
parent 18295e8e4f
commit d2495190a9
7 changed files with 132 additions and 2 deletions
@@ -0,0 +1,24 @@
"""add document title column
Revision ID: 0002
Revises: 0001
Create Date: 2026-04-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("documents", sa.Column("title", sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column("documents", "title")
@@ -16,6 +16,7 @@ class Document(Base):
file_path: Mapped[str] = mapped_column(String, nullable=False) file_path: Mapped[str] = mapped_column(String, nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False) file_size: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending") status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
document_type: Mapped[str | None] = mapped_column(String, nullable=True) document_type: Mapped[str | None] = mapped_column(String, nullable=True)
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True) raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
extracted_data: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string extracted_data: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string
+16 -1
View File
@@ -16,7 +16,7 @@ from app.deps import get_user_id
from app.models.category import DocumentCategory from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment from app.models.category_assignment import CategoryAssignment
from app.models.document import Document from app.models.document import Document
from app.schemas.document import DocumentOut, DocumentStatusOut, DocumentTypeUpdate, TagsUpdate from app.schemas.document import DocumentOut, DocumentStatusOut, DocumentTypeUpdate, TagsUpdate, TitleUpdate
from app.services.ai_client import AIServiceError, classify_document from app.services.ai_client import AIServiceError, classify_document
from app.services.config_reader import load_doc_config from app.services.config_reader import load_doc_config
from app.services.storage import delete_file, get_upload_path, save_upload from app.services.storage import delete_file, get_upload_path, save_upload
@@ -95,6 +95,7 @@ async def process_document(doc_id: str) -> None:
doc.raw_text = text[:500_000] # cap stored text at 500k chars doc.raw_text = text[:500_000] # cap stored text at 500k chars
doc.extracted_data = json.dumps(result) doc.extracted_data = json.dumps(result)
doc.title = result.get("title") or None
doc.document_type = result.get("document_type", "unknown") doc.document_type = result.get("document_type", "unknown")
doc.tags = json.dumps(result.get("tags", [])) doc.tags = json.dumps(result.get("tags", []))
doc.status = "done" doc.status = "done"
@@ -227,6 +228,20 @@ async def update_document_tags(
return _doc_with_categories(doc) return _doc_with_categories(doc)
@router.patch("/{doc_id}/title", response_model=DocumentOut)
async def update_document_title(
doc_id: str,
body: TitleUpdate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db),
) -> DocumentOut:
doc = await _get_user_doc(doc_id, user_id, db)
doc.title = body.title.strip() or None
await db.commit()
doc = await _get_user_doc(doc_id, user_id, db)
return _doc_with_categories(doc)
@router.delete("/{doc_id}", status_code=204) @router.delete("/{doc_id}", status_code=204)
async def delete_document( async def delete_document(
doc_id: str, doc_id: str,
@@ -13,6 +13,7 @@ class DocumentOut(BaseModel):
id: str id: str
user_id: str user_id: str
filename: str filename: str
title: str | None
file_size: int file_size: int
status: str status: str
document_type: str | None document_type: str | None
@@ -41,3 +42,7 @@ class DocumentTypeUpdate(BaseModel):
class TagsUpdate(BaseModel): class TagsUpdate(BaseModel):
tags: list[str] tags: list[str]
class TitleUpdate(BaseModel):
title: str
@@ -5,6 +5,7 @@ SYSTEM_PROMPT = (
) )
USER_PROMPT_TEMPLATE = """Analyze the following document text and return a JSON object with exactly these keys: USER_PROMPT_TEMPLATE = """Analyze the following document text and return a JSON object with exactly these keys:
title (a short, descriptive human-readable title for this document, e.g. "ACME Corp Invoice April 2026", "Office Supplies Receipt", "Q1 Flower Delivery Order"),
document_type (one of: invoice, bill, receipt, order, expense, revenue, unknown), document_type (one of: invoice, bill, receipt, order, expense, revenue, unknown),
total_amount (string or null), total_amount (string or null),
currency (string or null), currency (string or null),
+4
View File
@@ -86,6 +86,7 @@ export interface DocumentOut {
id: string; id: string;
user_id: string; user_id: string;
filename: string; filename: string;
title: string | null;
file_size: number; file_size: number;
status: DocumentStatus; status: DocumentStatus;
document_type: string | null; document_type: string | null;
@@ -148,6 +149,9 @@ export const viewDocument = async (id: string): Promise<void> => {
export const updateDocumentTags = (id: string, tags: string[]) => export const updateDocumentTags = (id: string, tags: string[]) =>
api.patch<DocumentOut>(`/documents/${id}/tags`, { tags }).then((r) => r.data); api.patch<DocumentOut>(`/documents/${id}/tags`, { tags }).then((r) => r.data);
export const updateDocumentTitle = (id: string, title: string) =>
api.patch<DocumentOut>(`/documents/${id}/title`, { title }).then((r) => r.data);
export const assignCategory = (docId: string, catId: string) => export const assignCategory = (docId: string, catId: string) =>
api.post(`/documents/${docId}/categories/${catId}`); api.post(`/documents/${docId}/categories/${catId}`);
+81 -1
View File
@@ -13,6 +13,7 @@ import {
assignCategory, assignCategory,
removeCategory, removeCategory,
updateDocumentTags, updateDocumentTags,
updateDocumentTitle,
type DocumentOut, type DocumentOut,
type CategoryOut, type CategoryOut,
} from "../api/client"; } from "../api/client";
@@ -89,6 +90,73 @@ function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipP
// ── Document row ──────────────────────────────────────────────────────────── // ── Document row ────────────────────────────────────────────────────────────
// ── Inline title editor ─────────────────────────────────────────────────────
function InlineTitleEditor({
docId,
currentTitle,
filename,
onSaved,
}: {
docId: string;
currentTitle: string | null;
filename: string;
onSaved: () => void;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(currentTitle ?? "");
const saveMut = useMutation({
mutationFn: (t: string) => updateDocumentTitle(docId, t),
onSuccess: () => { onSaved(); setEditing(false); },
});
if (!editing) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 500 }}>
{currentTitle ?? <span style={{ color: "#aaa", fontStyle: "italic" }}>{filename}</span>}
</span>
<button
onClick={(e) => { e.stopPropagation(); setValue(currentTitle ?? ""); setEditing(true); }}
style={{ fontSize: 11, cursor: "pointer", color: "#888", background: "none", border: "1px solid #ddd", borderRadius: 3, padding: "0 5px" }}
title="Edit title"
>
</button>
</span>
);
}
return (
<span onClick={(e) => e.stopPropagation()} style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: 1 }}>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveMut.mutate(value);
if (e.key === "Escape") { setEditing(false); setValue(currentTitle ?? ""); }
}}
style={{ fontSize: 14, padding: "2px 6px", border: "1px solid #888", borderRadius: 3, width: 280 }}
autoFocus
/>
<button
onClick={() => saveMut.mutate(value)}
disabled={saveMut.isPending}
style={{ fontSize: 12, cursor: "pointer", padding: "2px 8px", background: "#222", color: "#fff", border: "none", borderRadius: 3 }}
>
{saveMut.isPending ? "…" : "Save"}
</button>
<button
onClick={() => { setEditing(false); setValue(currentTitle ?? ""); }}
style={{ fontSize: 12, cursor: "pointer", padding: "2px 6px", border: "1px solid #ccc", borderRadius: 3, background: "none" }}
>
</button>
</span>
);
}
// ── Tag editor ────────────────────────────────────────────────────────────── // ── Tag editor ──────────────────────────────────────────────────────────────
function TagEditor({ function TagEditor({
@@ -290,7 +358,19 @@ function DocumentRow({
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)} onClick={() => setExpanded((e) => !e)}
> >
<span style={{ flex: 1, fontWeight: 500 }}>{doc.filename}</span> <span style={{ flex: 1 }}>
<InlineTitleEditor
docId={doc.id}
currentTitle={doc.title}
filename={doc.filename}
onSaved={() => qc.invalidateQueries({ queryKey: ["documents"] })}
/>
{doc.title && (
<span style={{ display: "block", fontSize: 11, color: "#999", marginTop: 1 }}>
{doc.filename}
</span>
)}
</span>
<StatusBadge status={doc.status} /> <StatusBadge status={doc.status} />
{doc.document_type && ( {doc.document_type && (
<span style={{ fontSize: 12, color: "#555" }}>{doc.document_type}</span> <span style={{ fontSize: 12, color: "#555" }}>{doc.document_type}</span>