Files
Stirling-PDF/engine/src/stirling/agents/document_classifier.py
T
EthanHealy01andGitHub 7f01bcdc44 Classifier setup as a processor policy (#7012)
## Overview

Adds a **Classification policy** to the processor's policy catalogue,
set up the same way as the Security policy. This moves classifier
configuration out of the editor (where the labels UI landed in #6898 and
was then removed with the rest of the editor's policy-management surface
in #6932) and into the processor, which is now the single place policies
are configured.

## What it does

- **Classification card** in the processor policy catalogue. Always
shown, but **setup is locked until the backend reports the AI engine is
on** — so admins can see the capability they're missing rather than it
being hidden entirely.
- **Setup wizard** mirrors Security: the workflow step shows the team's
**classification label editor** (reused
`LabelsEditor`/`LabelsEditorModal` — add box, chip grid, per-label icon
picker, import/export, reset) instead of tool toggles, since classify is
a single non-configurable step.
- On enable, the team's label vocabulary is **seeded with the 268
built-in defaults** (clobber-safe: only when the team has none). On
upload the document is classified against the team's labels and tagged;
on SaaS with the engine on, files group by category in the editor
sidebar.

## Reuse & consolidation

- Reuses the existing labels table, `labelsFile` helpers, and default
vocabulary. Labels read/write through the processor's own
`apiClient.local` (not the editor's axios client) so auth/base routing
stays explicit; the wire shape is shared.
- Consolidates policy-category icons into a shared, **id-keyed**
`policyCategoryIcon` util (outline glyphs) used by both the editor and
the processor, replacing the processor's emoji-glyph map (and the stray
`schedule` key that rendered a bare dot).

## Testing

- `task frontend:typecheck:{core,proprietary,portal}`,
`frontend:lint:eslint`, `frontend:test` (156 files / 1305 tests) — all
green.
- Verified in Storybook: the Classification card renders, the setup
wizard shows the label editor (268 defaults), and the full labels editor
opens with icons/import/export/reset. Added an MSW handler for the
app-config + labels endpoints and a `Classification` wizard story.

## Notes for reviewers

- The AI-engine gate reads the public `/api/v1/config/app-config`;
classification labels use `/api/v1/classification/labels` (team-scoped,
team-lead/admin-gated, `policies.enabled`); the classify step hits
`/api/v1/ai/tools/classify-and-label` — all pre-existing backend from
#6898.
- Known parity behavior (matches the editor hook): a transient failure
loading team labels falls back to showing the defaults; not changed here
to avoid diverging the two hooks.
2026-07-15 11:04:30 +00:00

135 lines
5.0 KiB
Python

from __future__ import annotations
import logging
from pydantic import Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts import (
ClassifyDocumentRequest,
ClassifyDocumentResponse,
DocumentClassificationResponse,
LabelOption,
PageText,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# A document carries at most this many labels; anything past the cap is dropped
# in the model's order.
MAX_ASSIGNED_LABELS = 5
# Pages read from each end of the document. A document's type is evident from
# its opening (and closing) pages, so a fixed window keeps cost and latency flat
# regardless of length. Promote to AppSettings if it ever needs tuning.
WINDOW_PAGES = 2
_SYSTEM_PROMPT = (
"You identify what a document is by assigning labels, choosing only from a "
"fixed list of allowed labels you are given.\n"
"\n"
"Rules:\n"
f"- Pick up to {MAX_ASSIGNED_LABELS} labels that describe this document's type.\n"
"- Only use labels from the allowed list, spelled exactly as listed.\n"
"- Return an empty list if none fit.\n"
"- Judge from the document's content and structure, not from keywords alone. "
"The document may be in any language.\n"
"- You are shown only the first and last pages; that is enough to identify the type."
)
class _ClassifierOutput(ApiModel):
"""Raw model answer, before it is validated against the allowed vocabulary."""
labels: list[str] = Field(
default_factory=list,
description="Labels from the allowed list that describe this document's type.",
)
def render_labels(labels: list[LabelOption]) -> str:
"""Render the allowed vocabulary for the prompt — the model reasons over the
human names (it never sees ids)."""
names = [label.name for label in labels]
return f"Allowed labels: {', '.join(names) or '(none)'}"
def select_window(pages: list[PageText], window: int = WINDOW_PAGES) -> list[PageText]:
"""Return the first and last ``window`` pages, never overlapping.
Documents short enough that the two ends would meet are returned whole. The
caller usually sends just the window already; this is a defensive trim in
case it sends more.
"""
if window <= 0 or len(pages) <= window * 2:
return list(pages)
return [*pages[:window], *pages[-window:]]
def format_window(pages: list[PageText]) -> str:
if not pages:
return "(no extractable text)"
return "\n\n".join(f"[Page {page.page_number}]\n{page.text}" for page in pages)
def validate_labels(output: _ClassifierOutput, allowed: list[LabelOption]) -> DocumentClassificationResponse:
"""Coerce a raw model answer (names) onto the allowed vocabulary, returning
label **ids**.
The model answers with names; they are matched case-insensitively to the
allowed vocabulary and returned as that label's id. Anything off-list is
dropped, duplicates collapse to the first occurrence, and the result is
capped at ``MAX_ASSIGNED_LABELS`` in the model's order. The model identifies;
these rules decide what is allowed to stand.
"""
id_by_lower_name = {label.name.lower(): label.id for label in allowed}
kept: list[str] = []
for name in output.labels:
label_id = id_by_lower_name.get(name.strip().lower())
if label_id is not None and label_id not in kept:
kept.append(label_id)
if len(kept) == MAX_ASSIGNED_LABELS:
break
return DocumentClassificationResponse(labels=kept)
class DocumentClassifierAgent:
"""Assigns labels to a document from an allowed vocabulary.
Reads the bounded page window supplied on the request (first/last
``WINDOW_PAGES``) and runs a single fast-model pass, then validates the
answer against the vocabulary so nothing off-list survives.
"""
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._agent = Agent(
model=runtime.fast_model,
output_type=NativeOutput(_ClassifierOutput),
system_prompt=_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
# The caller (the backend) always supplies the allowed vocabulary — its
# fixed built-in label set — so the engine holds no vocabulary of its own.
allowed = request.labels
window = select_window(request.pages)
prompt = self._build_prompt(request.file_name, allowed, window)
logger.debug("[classify] prompt:\n%s", prompt)
result = await self._agent.run(prompt)
return validate_labels(result.output, allowed)
@staticmethod
def _build_prompt(file_name: str, allowed: list[LabelOption], window: list[PageText]) -> str:
return (
f"{render_labels(allowed)}\n\n"
f"Document file name: {file_name}\n"
f"Document content (first and last pages):\n{format_window(window)}"
)