Add priority queue to ai-service and STATUS.md workflow

- Introduce async priority queue service in ai-service; all /chat calls now route through it
- Refactor chat router to separate execute_chat (core logic) from the HTTP handler
- Add /queue endpoints (status, pause, resume, cancel) for queue management
- Update ai-service config to use Pydantic v2 model_config style
- Add STATUS.md files for backend, ai-service, doc-service, and frontend
- Document STATUS.md workflow in CLAUDE.md
- Update doc-service documents router and schemas; frontend DocumentsPage and API client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-14 22:58:10 +02:00
parent d2495190a9
commit c4f0c7ad49
18 changed files with 1253 additions and 35 deletions
+19 -2
View File
@@ -98,6 +98,23 @@ export interface DocumentOut {
categories: CategoryOut[];
}
export interface DocumentPage {
items: DocumentOut[];
total: number;
page: number;
pages: number;
}
export interface DocumentListParams {
page?: number;
per_page?: number;
sort?: string;
order?: "asc" | "desc";
status?: string;
document_type?: string;
search?: string;
}
export interface DocumentStatusOut {
id: string;
status: DocumentStatus;
@@ -106,8 +123,8 @@ export interface DocumentStatusOut {
processed_at: string | null;
}
export const listDocuments = () =>
api.get<DocumentOut[]>("/documents").then((r) => r.data);
export const listDocuments = (params: DocumentListParams = {}) =>
api.get<DocumentPage>("/documents", { params }).then((r) => r.data);
export const getDocument = (id: string) =>
api.get<DocumentOut>(`/documents/${id}`).then((r) => r.data);
+182 -14
View File
@@ -1,4 +1,4 @@
import { useRef, useState, useEffect } from "react";
import { useRef, useState, useEffect, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Nav from "../components/Nav";
import {
@@ -16,6 +16,7 @@ import {
updateDocumentTitle,
type DocumentOut,
type CategoryOut,
type DocumentListParams,
} from "../api/client";
function StatusBadge({ status }: { status: DocumentOut["status"] }) {
@@ -504,6 +505,140 @@ function DocumentRow({
);
}
// ── Filter bar ──────────────────────────────────────────────────────────────
const SORT_OPTIONS = [
{ value: "created_at", label: "Upload date" },
{ value: "processed_at", label: "Processed date" },
{ value: "title", label: "Title" },
{ value: "filename", label: "Filename" },
{ value: "file_size", label: "File size" },
{ value: "document_type", label: "Type" },
{ value: "status", label: "Status" },
];
const STATUS_OPTIONS = ["pending", "processing", "done", "failed"];
const TYPE_OPTIONS = ["invoice", "bill", "receipt", "order", "expense", "revenue", "unknown"];
function FilterBar({
params,
onChange,
}: {
params: DocumentListParams;
onChange: (p: Partial<DocumentListParams>) => void;
}) {
const [searchInput, setSearchInput] = useState(params.search ?? "");
// Debounce search: commit after 400 ms of no typing
useEffect(() => {
const id = setTimeout(() => onChange({ search: searchInput || undefined, page: 1 }), 400);
return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 16, alignItems: "center" }}>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search title, filename, tags…"
style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, width: 220 }}
/>
<select
value={params.status ?? ""}
onChange={(e) => onChange({ status: e.target.value || undefined, page: 1 })}
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
>
<option value="">All statuses</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
value={params.document_type ?? ""}
onChange={(e) => onChange({ document_type: e.target.value || undefined, page: 1 })}
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
>
<option value="">All types</option>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
value={params.sort ?? "created_at"}
onChange={(e) => onChange({ sort: e.target.value, page: 1 })}
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button
onClick={() => onChange({ order: params.order === "asc" ? "desc" : "asc", page: 1 })}
title={params.order === "asc" ? "Ascending — click to reverse" : "Descending — click to reverse"}
style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, cursor: "pointer", background: "#fff" }}
>
{params.order === "asc" ? "↑ Asc" : "↓ Desc"}
</button>
{(params.search || params.status || params.document_type) && (
<button
onClick={() => { setSearchInput(""); onChange({ search: undefined, status: undefined, document_type: undefined, page: 1 }); }}
style={{ padding: "6px 10px", fontSize: 12, border: "1px solid #ddd", borderRadius: 4, cursor: "pointer", color: "#666", background: "#fafafa" }}
>
Clear filters
</button>
)}
</div>
);
}
// ── Pagination controls ──────────────────────────────────────────────────────
function Pagination({
page,
pages,
total,
perPage,
onChange,
}: {
page: number;
pages: number;
total: number;
perPage: number;
onChange: (p: number) => void;
}) {
const start = (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 16, fontSize: 13, color: "#555" }}>
<button
onClick={() => onChange(page - 1)}
disabled={page <= 1}
style={{ padding: "4px 10px", cursor: page > 1 ? "pointer" : "default", borderRadius: 4, border: "1px solid #ccc", background: "#fff" }}
>
Prev
</button>
<span>
{start}{end} of {total}
</span>
<button
onClick={() => onChange(page + 1)}
disabled={page >= pages}
style={{ padding: "4px 10px", cursor: page < pages ? "pointer" : "default", borderRadius: 4, border: "1px solid #ccc", background: "#fff" }}
>
Next
</button>
<span style={{ color: "#aaa" }}>Page {page} / {pages}</span>
</div>
);
}
// ── Page ────────────────────────────────────────────────────────────────────
export default function DocumentsPage() {
@@ -512,11 +647,26 @@ export default function DocumentsPage() {
const [newCatName, setNewCatName] = useState("");
const [uploadError, setUploadError] = useState<string | null>(null);
const { data: documents = [], isLoading } = useQuery({
queryKey: ["documents"],
queryFn: listDocuments,
const [params, setParams] = useState<DocumentListParams>({
page: 1,
per_page: 20,
sort: "created_at",
order: "desc",
});
const updateParams = useCallback((patch: Partial<DocumentListParams>) => {
setParams((prev) => ({ ...prev, ...patch }));
}, []);
const { data: docPage, isLoading } = useQuery({
queryKey: ["documents", params],
queryFn: () => listDocuments(params),
});
const documents = docPage?.items ?? [];
const total = docPage?.total ?? 0;
const pages = docPage?.pages ?? 1;
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
@@ -558,7 +708,7 @@ export default function DocumentsPage() {
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
<div style={{ padding: 32, maxWidth: 960, margin: "0 auto" }}>
<h1>Documents</h1>
{/* Upload */}
@@ -611,20 +761,38 @@ export default function DocumentsPage() {
</form>
</details>
{/* Filter bar */}
<FilterBar params={params} onChange={updateParams} />
{/* Document list */}
{isLoading ? (
<p>Loading</p>
) : documents.length === 0 ? (
<p style={{ color: "#666" }}>No documents yet. Upload a PDF to get started.</p>
<p style={{ color: "#666" }}>
{total === 0 && !params.search && !params.status && !params.document_type
? "No documents yet. Upload a PDF to get started."
: "No documents match the current filters."}
</p>
) : (
documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
categories={categories}
onDelete={(id) => deleteMut.mutate(id)}
/>
))
<>
{documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
categories={categories}
onDelete={(id) => deleteMut.mutate(id)}
/>
))}
{pages > 1 && (
<Pagination
page={params.page ?? 1}
pages={pages}
total={total}
perPage={params.per_page ?? 20}
onChange={(p) => updateParams({ page: p })}
/>
)}
</>
)}
</div>
</>