diff --git a/features/doc-service/alembic/versions/0002_add_document_title.py b/features/doc-service/alembic/versions/0002_add_document_title.py new file mode 100644 index 0000000..2000672 --- /dev/null +++ b/features/doc-service/alembic/versions/0002_add_document_title.py @@ -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") diff --git a/features/doc-service/app/models/document.py b/features/doc-service/app/models/document.py index 601a9e1..25d88fa 100644 --- a/features/doc-service/app/models/document.py +++ b/features/doc-service/app/models/document.py @@ -16,6 +16,7 @@ class Document(Base): file_path: Mapped[str] = mapped_column(String, nullable=False) file_size: Mapped[int] = mapped_column(Integer, nullable=False) 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) raw_text: Mapped[str | None] = mapped_column(Text, nullable=True) extracted_data: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string diff --git a/features/doc-service/app/routers/documents.py b/features/doc-service/app/routers/documents.py index eda9f09..5fb14c3 100644 --- a/features/doc-service/app/routers/documents.py +++ b/features/doc-service/app/routers/documents.py @@ -16,7 +16,7 @@ from app.deps import get_user_id from app.models.category import DocumentCategory from app.models.category_assignment import CategoryAssignment 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.config_reader import load_doc_config 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.extracted_data = json.dumps(result) + doc.title = result.get("title") or None doc.document_type = result.get("document_type", "unknown") doc.tags = json.dumps(result.get("tags", [])) doc.status = "done" @@ -227,6 +228,20 @@ async def update_document_tags( 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) async def delete_document( doc_id: str, diff --git a/features/doc-service/app/schemas/document.py b/features/doc-service/app/schemas/document.py index 33888aa..4ffbc01 100644 --- a/features/doc-service/app/schemas/document.py +++ b/features/doc-service/app/schemas/document.py @@ -13,6 +13,7 @@ class DocumentOut(BaseModel): id: str user_id: str filename: str + title: str | None file_size: int status: str document_type: str | None @@ -41,3 +42,7 @@ class DocumentTypeUpdate(BaseModel): class TagsUpdate(BaseModel): tags: list[str] + + +class TitleUpdate(BaseModel): + title: str diff --git a/features/doc-service/app/services/prompts.py b/features/doc-service/app/services/prompts.py index c7f9e94..66c18d3 100644 --- a/features/doc-service/app/services/prompts.py +++ b/features/doc-service/app/services/prompts.py @@ -5,6 +5,7 @@ SYSTEM_PROMPT = ( ) 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), total_amount (string or null), currency (string or null), diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 89349e8..daa6dfc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -86,6 +86,7 @@ export interface DocumentOut { id: string; user_id: string; filename: string; + title: string | null; file_size: number; status: DocumentStatus; document_type: string | null; @@ -148,6 +149,9 @@ export const viewDocument = async (id: string): Promise => { export const updateDocumentTags = (id: string, tags: string[]) => api.patch(`/documents/${id}/tags`, { tags }).then((r) => r.data); +export const updateDocumentTitle = (id: string, title: string) => + api.patch(`/documents/${id}/title`, { title }).then((r) => r.data); + export const assignCategory = (docId: string, catId: string) => api.post(`/documents/${docId}/categories/${catId}`); diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index b4bd5a1..632fd28 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -13,6 +13,7 @@ import { assignCategory, removeCategory, updateDocumentTags, + updateDocumentTitle, type DocumentOut, type CategoryOut, } from "../api/client"; @@ -89,6 +90,73 @@ function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipP // ── 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 ( + + + {currentTitle ?? {filename}} + + + + ); + } + + return ( + e.stopPropagation()} style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: 1 }}> + 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 + /> + + + + ); +} + // ── Tag editor ────────────────────────────────────────────────────────────── function TagEditor({ @@ -290,7 +358,19 @@ function DocumentRow({ style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 16px", cursor: "pointer" }} onClick={() => setExpanded((e) => !e)} > - {doc.filename} + + qc.invalidateQueries({ queryKey: ["documents"] })} + /> + {doc.title && ( + + {doc.filename} + + )} + {doc.document_type && ( {doc.document_type}