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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,4 @@ logs/
# ─── Misc ───
.streamlit/secrets.toml
tempCodeRunnerFile.py
.linvenv/
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ To run the AI pipeline end-to-end on the seeded corpus without the web stack:
make demo
```

> The dev `.venv` above is what we use day-to-day; it is **not** a
> container-parity copy of the Railway deploy image, and a few of the ways
> those two diverge are easy to trip over (e.g. a local dependency check that
> looks clean can still be 2+ GB heavier on Railway's Linux target). See
> **[docs/LOCAL_DEV.md](docs/LOCAL_DEV.md)** before trusting a local
> measurement as a stand-in for a deploy one.

---

## 🗂️ Repository Structure
Expand Down
141 changes: 130 additions & 11 deletions ai_pipeline/autoria_ai/conditioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,55 @@
to adopt an author's exact style, following the template in docs/MVP.md §4.3.

Latency contract
----------------
The returned string is kept under ~1200 tokens (architecture.md §6). The
safeguard is applied by truncating ``rag_chunks`` to at most 5 items before
they are interpolated.
-----------------
The returned string is kept at or under ``_MAX_PROMPT_TOKENS`` (1200 tokens,
cl100k_base) — the stricter of the two figures in circulation (architecture.md
§6 says "< ~1200 tok"; issue #23 said "< 2000"). Two safeguards apply, in
order:

1. ``rag_chunks`` is capped to at most ``_MAX_CHUNKS`` (5) items. This bounds
the *number* of passages but, since each chunk can be up to ~500 tokens
(the RAG chunking window — see ``backend/app/routes/authors.py`` and
``scripts/seed_corpus.py``), does **not** by itself bound token count: 5
chunks alone can exceed 2500 tokens before the rest of the template is
even added.
2. The capped chunks are then packed against the *actual* remaining token
budget (total budget minus everything else the template renders). Chunks
that fit whole are kept as-is; the first chunk that doesn't fit is
truncated to the last full sentence (or, failing that, the last full
word) within budget rather than being cut off mid-word or dropped
outright; any chunks after that are omitted.
"""

from __future__ import annotations

import re

import tiktoken

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_MAX_CHUNKS: int = 5
_MAX_VOCAB_TERMS: int = 15

# docs/architecture.md §6: "lean system prompt (< ~1200 tok)". This is the
# stricter of the two numbers in circulation (issue #23 cites "< 2000"); the
# more restrictive one wins per the module's dispatch instructions.
_MAX_PROMPT_TOKENS: int = 1200

# Loaded once at import, matching the pattern used by
# autoria_ai/extractor/chunker.py and autoria_ai/extractor/style_profile.py.
_ENCODER = tiktoken.get_encoding("cl100k_base")

_CHUNK_SEPARATOR = " | "
_NO_CHUNKS_FALLBACK = "(no example passages provided)"

# Sentence end: terminal punctuation, optional closing quote/bracket, then
# whitespace. Used to back a truncated passage off to a clean boundary.
_SENTENCE_END_RE = re.compile(r"[.!?][\"'\u201d\u2019)\]]*\s")

# ---------------------------------------------------------------------------
# Public function
# ---------------------------------------------------------------------------
Expand All @@ -36,6 +70,56 @@
)


def _token_count(text: str) -> int:
return len(_ENCODER.encode(text))


def _clip_to_boundary(text: str) -> str:
"""Back `text` off to its last full sentence, else its last full word.

Returns "" if neither a sentence nor a word boundary exists (e.g. `text`
is a single partial word), so the caller can drop it instead of emitting
a fragment.
"""
matches = list(_SENTENCE_END_RE.finditer(text))
if matches:
return text[: matches[-1].end()].rstrip()
last_space = text.rfind(" ")
if last_space > 0:
return text[:last_space].rstrip()
return ""


def _fit_chunks_to_token_budget(chunks: list[str], budget_tokens: int) -> list[str]:
"""Pack `chunks` in order into `budget_tokens`, truncating the first one
that doesn't fit whole to a clean boundary, and dropping the rest.
"""
if budget_tokens <= 0:
return []

kept: list[str] = []
used = 0
for chunk in chunks:
sep_cost = _token_count(_CHUNK_SEPARATOR) if kept else 0
remaining = budget_tokens - used - sep_cost
if remaining <= 0:
break

chunk_tokens = _ENCODER.encode(chunk)
if len(chunk_tokens) <= remaining:
kept.append(chunk)
used += sep_cost + len(chunk_tokens)
continue

truncated = _ENCODER.decode(chunk_tokens[:remaining]).strip()
clipped = _clip_to_boundary(truncated)
if clipped:
kept.append(clipped)
break

return kept


def build_system_prompt(style_profile: dict, rag_chunks: list[str]) -> str:
"""Compose the conditioned system prompt for the Watsonx LLM.

Expand All @@ -47,14 +131,17 @@ def build_system_prompt(style_profile: dict, rag_chunks: list[str]) -> str:
on a partial profile.
rag_chunks:
Retrieved example passages (top-k by cosine similarity from pgvector).
At most ``_MAX_CHUNKS`` (5) are used; any extras are silently dropped to
keep the prompt within the ~1200-token latency budget.
At most ``_MAX_CHUNKS`` (5) are considered; the result is then packed
into the remaining token budget (see module docstring), so the
returned prompt never exceeds ``_MAX_PROMPT_TOKENS`` tokens
regardless of how long the individual chunks are.

Returns
-------
str
A fully-rendered system prompt string ready to be passed as the
``system`` parameter of a Watsonx chat-completion call.
``system`` parameter of a Watsonx chat-completion call, guaranteed to
be at most ``_MAX_PROMPT_TOKENS`` tokens under cl100k_base.
"""
# -- author id -------------------------------------------------------------
author_id: str = style_profile.get("author_id", "unknown")
Expand Down Expand Up @@ -85,14 +172,46 @@ def build_system_prompt(style_profile: dict, rag_chunks: list[str]) -> str:
]
vocab_list: str = ", ".join(top_terms) if top_terms else "vivid and precise language"

# -- rag chunks (enforce hard cap of 5) ------------------------------------
safe_chunks: list[str] = rag_chunks[:_MAX_CHUNKS]
chunks_text = " | ".join(safe_chunks) if safe_chunks else "(no example passages provided)"
# -- rag chunks: count cap first (existing safeguard), then token budget --
count_capped_chunks: list[str] = rag_chunks[:_MAX_CHUNKS]

# Everything except `chunks` is fixed at this point, so the true chunk
# budget is whatever tokens are left after rendering the rest of the
# template with the "no passages" fallback in place of the real chunks.
fixed_prompt = _TEMPLATE.format(
author_id=author_id,
avg_sentence_length=avg_sl_str,
subordination_rule=subordination_rule,
vocab_list=vocab_list,
chunks=_NO_CHUNKS_FALLBACK,
)
fixed_cost = _token_count(fixed_prompt) - _token_count(_NO_CHUNKS_FALLBACK)
chunk_budget = max(0, _MAX_PROMPT_TOKENS - fixed_cost)

safe_chunks = _fit_chunks_to_token_budget(count_capped_chunks, chunk_budget)
chunks_text = _CHUNK_SEPARATOR.join(safe_chunks) if safe_chunks else _NO_CHUNKS_FALLBACK

return _TEMPLATE.format(
prompt = _TEMPLATE.format(
author_id=author_id,
avg_sentence_length=avg_sl_str,
subordination_rule=subordination_rule,
vocab_list=vocab_list,
chunks=chunks_text,
)

# Defense in depth: BPE merges across the chunk/template boundary can
# shift the count by a token or two relative to the estimate above. If
# that ever pushes the total over budget, drop the last chunk and retry
# rather than ship a prompt over the contract.
while _token_count(prompt) > _MAX_PROMPT_TOKENS and safe_chunks:
safe_chunks = safe_chunks[:-1]
chunks_text = _CHUNK_SEPARATOR.join(safe_chunks) if safe_chunks else _NO_CHUNKS_FALLBACK
prompt = _TEMPLATE.format(
author_id=author_id,
avg_sentence_length=avg_sl_str,
subordination_rule=subordination_rule,
vocab_list=vocab_list,
chunks=chunks_text,
)

return prompt
56 changes: 45 additions & 11 deletions ai_pipeline/autoria_ai/embedder.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,30 @@
----------
embed_chunks(texts: list[str]) -> np.ndarray
Batch-encode *texts* and return a float32 array of shape ``(N, 768)``.
ensure_model_loaded() -> SentenceTransformer
Force the model to load now (used by ``autoria_ai.generator.warmup_models``
to pre-warm at process startup) and return the singleton.

The ``SentenceTransformer`` model is loaded **once** at module import as a
module-level singleton. Callers must never reload it per request.
Model loading
-------------
The ``SentenceTransformer`` is a **lazily-constructed** process-level
singleton: it loads on first call to ``embed_chunks`` or
``ensure_model_loaded``, not at import time (#104). Constructing it used to
happen as a bare module-level statement, so *any* import of this module —
including ``autoria_ai.db`` and ``autoria_ai.extractor.style_profile``, which
import it just for ``EMBEDDING_DIM`` / ``embed_chunks`` and may not need to
actually embed anything — paid the full ~418 MB HuggingFace download/load
unconditionally. Lazy construction means importing this module is cheap; the
cost is paid exactly once, by whichever caller first needs embeddings.

This does **not** by itself remove the cost from a cold Railway boot: prod
still calls ``ensure_model_loaded()`` during FastAPI's lifespan (via
``autoria_ai.generator.warmup_models``) so the first real request isn't the
one paying for it. What it removes is the download turning into *network*
time at all when it can be avoided: ``railway.toml``'s ``buildCommand`` now
pre-fetches this model into the HuggingFace cache at **build** time (the same
technique already used there for the spaCy model), so a cold boot loads it
from local disk instead of downloading it fresh, deploy or restart.
"""

from __future__ import annotations
Expand All @@ -15,18 +36,30 @@
from sentence_transformers import SentenceTransformer # type: ignore[import-untyped]

# ---------------------------------------------------------------------------
# Module-level singleton — loaded once, reused for every call.
# The model name is the canonical one from docs/MVP.md §4.1 and
# docs/style_features.md §5.
# Module-level singleton — constructed lazily on first use (see module
# docstring), reused for every call afterwards. The model name is the
# canonical one from docs/MVP.md §4.1 and docs/style_features.md §5.
# ---------------------------------------------------------------------------
_MODEL_NAME = "all-mpnet-base-v2"
_MODEL: SentenceTransformer = SentenceTransformer(_MODEL_NAME)
_MODEL: SentenceTransformer | None = None

#: Dimensionality guaranteed by all-mpnet-base-v2 — used as the assertion
#: guard in embed_chunks() and as the vector(768) column width in the DB.
EMBEDDING_DIM: int = 768


def ensure_model_loaded() -> SentenceTransformer:
"""Return the process-level SentenceTransformer singleton, loading it
on the first call. Safe to call more than once or from more than one
caller (e.g. an explicit warmup *and* the first real ``embed_chunks``
call racing on it) — the model is constructed at most once per process.
"""
global _MODEL
if _MODEL is None:
_MODEL = SentenceTransformer(_MODEL_NAME)
return _MODEL


def embed_chunks(texts: list[str]) -> np.ndarray:
"""Batch-encode *texts* and return embeddings as a float32 NumPy array.

Expand All @@ -44,16 +77,17 @@ def embed_chunks(texts: list[str]) -> np.ndarray:

Notes
-----
* Uses ``_MODEL.encode()`` in batch mode with ``convert_to_numpy=True``
so the result is a contiguous C-order float32 array compatible with
* Uses ``.encode()`` in batch mode with ``convert_to_numpy=True`` so
the result is a contiguous C-order float32 array compatible with
pgvector's ``VECTOR(768)`` column.
* The model is **not** reloaded here; the module-level ``_MODEL``
singleton is used throughout the process lifetime.
* The model singleton is loaded on first call (see
``ensure_model_loaded``) and never reloaded per request.
"""
if not texts:
return np.zeros((0, EMBEDDING_DIM), dtype=np.float32)

embeddings: np.ndarray = _MODEL.encode(
model = ensure_model_loaded()
embeddings: np.ndarray = model.encode(
texts,
batch_size=64,
convert_to_numpy=True,
Expand Down
7 changes: 4 additions & 3 deletions ai_pipeline/autoria_ai/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ def _ensure_models() -> tuple[Any, Any]:
_nlp = spacy.load("en_core_web_lg")
logger.info("spaCy en_core_web_lg ready.")
if _embedding_model is None:
# Reuse embedder singleton (loads all-mpnet-base-v2 once).
from autoria_ai.embedder import _MODEL as emb
# Reuse embedder singleton, loading all-mpnet-base-v2 on this first
# call rather than at import time (#104).
from autoria_ai.embedder import ensure_model_loaded

_embedding_model = emb
_embedding_model = ensure_model_loaded()
logger.info("sentence-transformers embedding model ready.")
if _tok_enc is None:
_tok_enc = tiktoken.get_encoding("cl100k_base")
Expand Down
3 changes: 3 additions & 0 deletions ai_pipeline/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ dependencies = [
"umap-learn>=0.5",
"tiktoken>=0.7",
"numpy>=1.26",
# Direct import in autoria_ai/fit_scorer.py:22. Previously undeclared and
# only present transitively via scikit-learn (see #105).
"scipy>=1.11",
"pydantic>=2.7",
"python-jose[cryptography]>=3.3",
"jsonschema[format]>=4.18",
Expand Down
Loading
Loading