Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
781 changes: 774 additions & 7 deletions clams/app/__init__.py

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions clams/appmetadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,14 @@ class AppMetadata(pydantic.BaseModel):
None,
description="(optional) A string-to-string map that can be used to store any additional metadata of the app."
)
app_tags: List[str] = pydantic.Field(
[],
description="(optional) A list of short string labels that classify what kind of work this app does "
"(e.g. task name, output profile family). Used by downstream consumers as a first-pass filter "
"for selecting views; not a substitute for inspecting actual output types and properties. "
"The values declared here are propagated by the SDK into the ``appTags`` field of every view "
"the app signs."
)
est_gpu_mem_min: int = pydantic.Field(
0,
description="(optional) Minimum GPU memory required to run the app, in megabytes (MB). "
Expand Down Expand Up @@ -472,6 +480,19 @@ def add_input_oneof(self, *inputs: Union[str, Input, vocabulary.ThingTypesBase])
newinputs.append(i)
self.input.append(newinputs)

def add_app_tag(self, *tags: str) -> None:
"""
Helper method to add one or more strings to the ``app_tags`` list,
skipping any value that is already present.

:param tags: one or more tag strings to add
"""
for tag in tags:
if not isinstance(tag, str) or not tag:
raise ValueError(f"app tag must be a non-empty string: {tag!r}")
if tag not in self.app_tags:
self.app_tags.append(tag)

def add_output(self, at_type: Union[str, vocabulary.ThingTypesBase], **properties) -> Output:
"""
Helper method to add an element to the ``output`` list.
Expand Down
9 changes: 9 additions & 0 deletions clams/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""
Optional model-backend helpers for CLAMS apps.

Each backend is a separate submodule. Heavy dependencies (e.g.,
``torch``, ``transformers``) are NOT pulled in by the base
``clams-python`` install; users opt in via pip extras such as
``pip install clams-python[hf]`` for the HuggingFace transformers
backend.
"""
247 changes: 247 additions & 0 deletions clams/backends/hf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
"""
HuggingFace transformers backend helpers.

Two general loaders that wrap the device / kwargs / inference-mode
boilerplate every HF-backed CLAMS app does identically:

* :func:`load_hf_model` -- ``from_pretrained()`` flow for any model
class (instruction-tuned LLMs/VLMs, encoder-only classifiers,
vision/audio feature extractors, etc.). Use when the app needs raw
access to the underlying model and processor.
* :func:`load_hf_pipeline` -- task-level :func:`transformers.pipeline`
flow (ASR, NER, text classification, zero-shot, etc.). Use when
pipeline-level inference is sufficient.

``torch`` and ``transformers`` are optional dependencies. Install them
via the ``[hf]`` extra::

pip install clams-python[hf]

Imports are lazy: this module can be referenced from
:mod:`clams.app` without triggering an ``ImportError`` on a base
``clams-python`` install. The :class:`ImportError` only fires when a
loader is actually called without the extras.
"""
from typing import Any, Optional, Tuple, Union


def load_hf_model(
model_id: str,
model_cls,
processor_cls=None,
dtype=None,
device: Optional[str] = None,
padding_side: Optional[str] = None,
revision: Optional[str] = None,
model_kwargs: Optional[dict] = None,
processor_kwargs: Optional[dict] = None,
move_to_device: bool = True,
) -> Tuple[Any, Any, str]:
"""
Load a HuggingFace ``transformers`` model via ``from_pretrained``
and return it ready for inference.

:param model_id: HuggingFace model identifier (e.g., a Hub repo
name or a local path) forwarded to ``from_pretrained``.
:param model_cls: a ``transformers`` model class (e.g.,
``AutoModelForCausalLM``, ``AutoModelForImageTextToText``,
``ConvNextV2Model``, ``ViTModel``, ...). Whatever supports
``from_pretrained()``.
:param processor_cls: a processor / tokenizer / feature-extractor
class with ``from_pretrained()``. Defaults to
``transformers.AutoProcessor``. Pass ``transformers.AutoTokenizer``,
``transformers.AutoImageProcessor``, etc. for narrower cases.
Pass ``None`` explicitly to skip processor loading entirely
(the returned ``processor`` in that case is ``None``).
:param dtype: torch dtype for the model (e.g., ``torch.bfloat16``).
When ``None`` (default), no ``torch_dtype`` kwarg is forwarded
to ``from_pretrained`` -- the model class uses its own default
(typically float32). Set explicitly for low-precision LLM
inference.
:param device: target device string (e.g., ``'cuda'``, ``'cpu'``,
``'cuda:0'``). When ``None`` (default), the helper auto-detects
cuda availability and falls back to cpu.
:param padding_side: if set (typically ``'left'`` for decoder-only
models doing batched generation), the helper configures the
underlying tokenizer's ``padding_side`` and -- when no pad
token is set -- uses the EOS token as the pad token. Leave
``None`` for encoder / non-batched cases (the tokenizer's own
default is preserved).
:param revision: optional Git revision (commit hash, branch name,
or tag) on the Hub repository to pin the download to. When
set, forwarded as ``revision=...`` to both
``model_cls.from_pretrained`` and
``processor_cls.from_pretrained``, ensuring the model and
processor are loaded from the same commit. Strongly recommended
for production: pinning a commit hash makes the analyzer
artifact reproducible and immune to upstream silent updates.
Apps calling this helper directly should record the same hash
on ``analyzer_version`` (or ``analyzer_versions``) in
``metadata.py`` so the output MMIF identifies the exact
artifact. Apps inheriting from
:class:`~clams.app.ClamsHFPromptableApp` do not call this
helper -- the base class reads ``analyzer_versions`` from the
app metadata and forwards the resolved revision automatically.
:param model_kwargs: extra kwargs forwarded to
``model_cls.from_pretrained()`` (e.g.,
``{'use_safetensors': True, 'add_pooling_layer': False}``).
:param processor_kwargs: extra kwargs forwarded to
``processor_cls.from_pretrained()`` (e.g.,
``{'use_safetensors': True, 'use_fast': True}``).
:param move_to_device: when ``True`` (default), the helper moves
the loaded model to the resolved device and switches it to
``eval()`` mode -- the right behavior for a "ready for
inference" app loader. When ``False``, both steps are
skipped; the model is returned in the state
``from_pretrained`` left it (on CPU, in train mode). Use
``False`` for library-style HF wrappers that defer device
placement and inference-mode switching to a downstream
consumer (e.g. an extractor class that may be combined with
a head and only then placed on a device by the wrapping
classifier). The returned ``device`` is still the resolved
target, so the consumer can use it later for its own
``.to(device)`` call.

:returns: ``(processor, model, device)`` tuple. ``processor`` is
the loaded processor/tokenizer/feature-extractor (or ``None``
if ``processor_cls`` was explicitly set to ``None``).
``device`` is the resolved device string (the model was moved
there iff ``move_to_device=True``).
:rtype: Tuple[Any, Any, str]
:raises ImportError: if ``torch`` or ``transformers`` is not
installed. Install the ``[hf]`` extra to fix.
"""
try:
import torch # pytype: disable=import-error
except ImportError as e:
raise ImportError(
"clams.backends.hf requires the `torch` package. "
"Install with: pip install clams-python[hf]"
) from e
try:
import transformers # pytype: disable=import-error
except ImportError as e:
raise ImportError(
"clams.backends.hf requires the `transformers` package. "
"Install with: pip install clams-python[hf]"
) from e

resolved_device = device or ('cuda' if torch.cuda.is_available() else 'cpu')

# Processor.
if processor_cls is None and processor_kwargs is None:
# default to AutoProcessor
processor_cls = transformers.AutoProcessor
if processor_cls is not None:
processor_load_kwargs = dict(processor_kwargs or {})
if revision is not None:
processor_load_kwargs.setdefault('revision', revision)
processor = processor_cls.from_pretrained(
model_id, **processor_load_kwargs)
if padding_side is not None:
tokenizer = getattr(processor, 'tokenizer', processor)
tokenizer.padding_side = padding_side
if getattr(tokenizer, 'pad_token', None) is None:
eos = getattr(tokenizer, 'eos_token', None)
if eos is not None:
tokenizer.pad_token = eos
else:
processor = None

# Model.
model_load_kwargs = dict(model_kwargs or {})
if dtype is not None:
model_load_kwargs['torch_dtype'] = dtype
if revision is not None:
model_load_kwargs.setdefault('revision', revision)
model = model_cls.from_pretrained(model_id, **model_load_kwargs)
if move_to_device:
model = model.to(resolved_device)
model.eval()

return processor, model, resolved_device


def load_hf_pipeline(
task: str,
model_id: str,
device: Optional[Union[str, int]] = None,
revision: Optional[str] = None,
model_kwargs: Optional[dict] = None,
pipeline_kwargs: Optional[dict] = None,
) -> Tuple[Any, Union[str, int]]:
"""
Load a HuggingFace :func:`transformers.pipeline` for ``task`` and
return it ready for inference. Wraps the device / revision /
kwargs-forwarding boilerplate that every pipeline-backed CLAMS
app does identically. Use this for apps wrapping a task-level
pipeline (ASR via ``"automatic-speech-recognition"``, NER via
``"token-classification"``, text classification, zero-shot, etc.);
use :func:`load_hf_model` instead when the app needs raw access
to the underlying model / processor (e.g., for custom chat-template
formatting or batched ``generate`` calls).

:param task: pipeline task string forwarded to
:func:`transformers.pipeline` (e.g.,
``"automatic-speech-recognition"``, ``"token-classification"``).
:param model_id: HuggingFace model identifier (Hub repo name or
local path) forwarded to ``pipeline(model=...)``.
:param device: target device. Accepts the string form
(``'cuda'``, ``'cpu'``, ``'cuda:0'``) for parity with
:func:`load_hf_model`, or the integer form accepted natively
by ``pipeline`` (``-1`` for CPU, ``0+`` for GPU index). When
``None`` (default), auto-detects cuda availability and falls
back to cpu (string form).
:param revision: optional Git revision (commit hash, branch, or
tag) on the Hub to pin the download to. Strongly recommended
for production; see :func:`load_hf_model` for rationale.
:param model_kwargs: extra kwargs forwarded to the underlying
``model.from_pretrained()`` via the
``pipeline(model_kwargs={...})`` channel.
:param pipeline_kwargs: extra kwargs forwarded directly to
:func:`transformers.pipeline` (e.g. ``generate_kwargs``,
``tokenizer``, ``feature_extractor``, ``batch_size``,
``framework``). ``model``, ``task``, ``device``, ``revision``,
and ``model_kwargs`` are owned by this helper -- explicit
helper args take precedence if any collide.
:returns: ``(pipeline, device)`` tuple. ``device`` is the resolved
device the pipeline is on, in the form it was passed (or the
auto-resolved string form when ``device=None``).
:rtype: Tuple[Any, Union[str, int]]
:raises ImportError: if ``torch`` or ``transformers`` is not
installed. Install the ``[hf]`` extra to fix.
"""
try:
import torch # pytype: disable=import-error
except ImportError as e:
raise ImportError(
"clams.backends.hf requires the `torch` package. "
"Install with: pip install clams-python[hf]"
) from e
try:
from transformers import pipeline # pytype: disable=import-error
except ImportError as e:
raise ImportError(
"clams.backends.hf requires the `transformers` package. "
"Install with: pip install clams-python[hf]"
) from e

resolved_device = device if device is not None else (
'cuda' if torch.cuda.is_available() else 'cpu')

pipeline_call_kwargs = dict(pipeline_kwargs or {})
# Helper-owned keys: explicit args win on collision.
for owned in ('task', 'model', 'device'):
pipeline_call_kwargs.pop(owned, None)
if model_kwargs:
pipeline_call_kwargs['model_kwargs'] = dict(model_kwargs)
if revision is not None:
pipeline_call_kwargs['revision'] = revision

pipe = pipeline(
task,
model=model_id,
device=resolved_device,
**pipeline_call_kwargs,
)
return pipe, resolved_device
18 changes: 16 additions & 2 deletions clams/develop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@
'description': 'GtiHub Actions workflow files specific to `clamsproject` GitHub organization',
'sourcedir': 'gha',
'targetdir': '.github',
}
},
'utl-tf': {
'description': 'Local helper module for iterating TimeFrames and collecting per-TF frame tasks '
'(baked into ``utils/timeframe.py``; backend-agnostic, safe to edit/delete)',
'sourcedir': 'utl-tf',
'targetdir': 'utils',
},
}


Expand Down Expand Up @@ -65,12 +71,20 @@ def bake(self, update_level=0):
if recipe == 'gha':
# There's nothing for devs to tweak GHA template, so first generation and updating are the same.
self.bake_gha(src_dir, dst_dir)
if recipe.startswith('utl-'):
# Utility recipes bake static helper modules; once baked the
# code is local to the app and devs are free to edit. No
# templating-variable substitution is needed -- pass an
# empty dict so ``safe_substitute`` is a no-op.
if dst_dir.exists() and update_level == 0:
raise FileExistsError(f" {dst_dir} already exists. Did you mean `--update`? ")
self.bake_app(src_dir, dst_dir, {})

def bake_app(self, src_dir, dst_dir, templating_vars):
for g in src_dir.glob("**/*.template"):
r = g.relative_to(src_dir).parent
f = g.with_suffix('').name
(dst_dir / r).mkdir(exist_ok=True)
(dst_dir / r).mkdir(parents=True, exist_ok=True)

with open(g, 'r') as in_f, open(dst_dir/r/f, 'w') as out_f:
tmpl_to_compile = Template(in_f.read())
Expand Down
49 changes: 47 additions & 2 deletions clams/develop/templates/app/app.py.template
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,64 @@ from mmif import Mmif, View, Annotation, Document, AnnotationTypes, DocumentType
from lapps.discriminators import Uri


# =============================================================================
# Pick a base class for your app:
#
# ClamsApp ............ default; the rest of this scaffold inherits from it.
# Implement ``_annotate()``. That's it.
# Choose for any non-LLM/VLM app: classical OCR /
# ASR engines, classifiers, rule-based tools, etc.
#
# ClamsPromptableApp .. for prompt-driven LLM/VLM/ALM/LMM apps wrapping a
# non-HF backend (remote APIs like OpenAI/Anthropic,
# vLLM, custom inference servers).
# Implement: ``_annotate()`` + ``generate()``.
# Import:
# from clams import ClamsPromptableApp
# Also in ``metadata.py``: uncomment the
# ``inject_promptable_parameters`` block.
#
# ClamsHFPromptableApp for prompt-driven apps wrapping a local HuggingFace
# ``transformers`` model (the typical VLM/LLM case).
# Implement: ``_annotate()`` (call
# ``self.load_model(parameters['model'])`` first) +
# declare class attributes:
# MODEL_CLS = <transformers.AutoModelFor...>
# DTYPE = torch.bfloat16 # optional
# PADDING_SIDE = 'left' # optional
# Import:
# from clams.app import ClamsHFPromptableApp
# Also in ``metadata.py``: set
# ``analyzer_versions={<hf-id>: <commit-hash>, ...}``
# on the ``AppMetadata`` call, and uncomment the
# ``ClamsHFPromptableApp.inject_promptable_parameters``
# block (the HF override of the plain helper).
# Requires the ``[hf]`` extra:
# pip install clams-python[hf]
# Singleton ``analyzer_versions`` families pre-load
# in ``__init__`` (warm start); multi-member
# families load on the first ``load_model`` call
# and cache thereafter. ``generate()``,
# ``build_conversation``, and ``build_gen_kwargs``
# have working defaults; override only for
# model-specific quirks.
#
# See https://clams.ai/clams-python/app-baseclasses.html for the full
# developer guide.
# =============================================================================
class $APP_CLASS_NAME(ClamsApp):

def __init__(self):
super().__init__()

def _appmetadata(self):
# see https://sdk.clams.ai/autodoc/clams.app.html#clams.app.ClamsApp._load_appmetadata
# see https://clams.ai/clams-python/autodoc/clams.app.html#clams.app.ClamsApp._load_appmetadata
# Also check out ``metadata.py`` in this directory.
# When using the ``metadata.py`` leave this do-nothing "pass" method here.
pass

def _annotate(self, mmif: Mmif, **parameters) -> Mmif:
# see https://sdk.clams.ai/autodoc/clams.app.html#clams.app.ClamsApp._annotate
# see https://clams.ai/clams-python/autodoc/clams.app.html#clams.app.ClamsApp._annotate
raise NotImplementedError

def get_app():
Expand Down
Loading
Loading