diff --git a/.dockerignore b/.dockerignore index aac5ef5..8aa2866 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ __pycache__ .pytest_cache .pytest-tmp pytest-tmp +outputs .ruff_cache .git .agents diff --git a/.gcloudignore b/.gcloudignore index e44d575..141dc68 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -9,6 +9,7 @@ .pytest_cache .pytest-tmp pytest-tmp +outputs .ruff_cache .vscode __pycache__ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c8f2a1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,117 @@ +# Changelog + +This project records user-visible features, quality improvements, and deployment +milestones over time. Historical entries before July 12, 2026 were reconstructed +from the repository's commit history. + +## 2026-07-12 + +### Added + +- Added JSON protection and selective text compression based on tenant schema. +- Added tenant-authorized `` tagging, safe JSONPath + allowlists, bounded per-value compression, deterministic reconstruction, and + TOON-or-verbatim protection for the complete structure. +- Added dedicated tagged JSON compression documentation and an in-app changelog. + +### Changed + +- Valid JSON objects and arrays are now protected from model compression even + when they are too small to benefit from TOON conversion. +- JSON size now determines TOON eligibility rather than protection eligibility. + +## 2026-07-11 + +### Changed + +- Improved `/compress` diagnostic results for benchmarking and attribution. + +## 2026-07-10 + +### Added + +- Added a streamlined iframe embedding demo. + +## 2026-07-07 + +### Changed + +- Normalized aggressiveness settings across compression paths. +- Improved the edge compression function. + +## 2026-07-06 + +### Added + +- Added the Cloudflare edge compression worker. +- Added an initial GPU Docker and Cloud Run configuration. +- Added deterministic HTML compression. + +## 2026-07-05 + +### Added + +- Added GPU benchmark tests and recommendations. + +### Changed + +- Implemented additional deterministic compression optimizations. + +## 2026-06-30 + +### Changed + +- Improved evaluation coverage. +- Added deployment version and timestamp fields to `/health`. + +## 2026-06-27 + +### Added + +- Added tenant compression settings and LoRA examples. + +### Changed + +- Improved token estimation by preferring the model tokenizer, then tiktoken, + then the regex fallback. + +## 2026-06-26 + +### Documentation + +- Added the tenant adaptation plan. + +## 2026-06-25 + +### Added + +- Added the evaluation suite and established a passing baseline. + +### Fixed + +- Fixed Docker deployment of the data directory. + +## 2026-06-24 + +### Changed + +- Improved runtime performance. +- Refined the HTML and JSON preprocessing pipelines. + +## 2026-06-23 + +### Added + +- Added TOON and deterministic whitespace pipelines. +- Added the initial test-harness application. +- Prepared the Docker deployment path for Google Cloud Run. + +### Fixed + +- Expanded tests and fixed whitespace handling bugs. + +## Initial release + +### Added + +- Established the PromptCompression project and its initial API/runtime shape. diff --git a/Dockerfile b/Dockerfile index e39ef0b..64ec19b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ RUN adduser --disabled-password --gecos "" appuser \ COPY --chown=appuser:appuser app ./app COPY --chown=appuser:appuser data ./data COPY --chown=appuser:appuser models ./models +COPY --chown=appuser:appuser CHANGELOG.md ./CHANGELOG.md USER appuser diff --git a/README.md b/README.md index 959e0e1..e47f687 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,37 @@ Tenant fields are optional. They are request scoped and are not loaded from a local database. If `aggressiveness` is omitted, `tenant_profile.default_aggressiveness` is used when provided. +Tagged JSON can opt into tenant-approved compression of selected long string +values while keeping keys, types, arrays, and all other values deterministic: + +```json +{ + "tenant_profile": { + "json_compression_policy_id": "issue-v1", + "json_value_compression_paths": [ + "$.description", + "$.comments[*].body" + ], + "json_value_min_tokens": 200, + "json_value_max_reduction": 0.25, + "json_value_max_values": 8 + }, + "text": "Review this issue:\n{\"id\":\"ISSUE-73\",\"description\":\"Long narrative...\"}" +} +``` + +The tag cannot authorize fields by itself: its policy must match the tenant +profile and only allowlisted string paths are eligible. Each accepted value is +compressed independently and JSON-escaped during reconstruction. The rebuilt +object is then TOON-encoded when beneficial, or otherwise protected verbatim +before any outer model-compression call. Invalid JSON, duplicate keys, +unapproved policies, unlisted paths, and values that fail the token or maximum +reduction gates are not model-compressed. + +See [Tagged JSON Compression](docs/tagged-json-compression.md) for the complete +tag grammar, tenant schema, supported path syntax, safety gates, mode behavior, +fallback warnings, and operational guidance. + Set `include_sections` to `true` only for UI/debug views that need per-section labels and protected-block rendering. It defaults to `false` to keep responses small and skip word-label generation. diff --git a/app/benchmark_ui.py b/app/benchmark_ui.py index f9ea6a3..8afcc8d 100644 --- a/app/benchmark_ui.py +++ b/app/benchmark_ui.py @@ -341,6 +341,7 @@ Compression UI Eval Suite Research + Changelog API Docs diff --git a/app/changelog_ui.py b/app/changelog_ui.py new file mode 100644 index 0000000..2c389df --- /dev/null +++ b/app/changelog_ui.py @@ -0,0 +1,170 @@ +from html import escape +from pathlib import Path +import re + +from app.version import DEPLOYMENT_VERSION + + +CHANGELOG_PATH = Path(__file__).resolve().parents[1] / "CHANGELOG.md" + + +def _inline_markdown(text: str) -> str: + escaped = escape(text) + return re.sub(r"`([^`]+)`", r"\1", escaped) + + +def _render_changelog(markdown: str) -> str: + parts: list[str] = [] + paragraphs: list[str] = [] + list_items: list[str] = [] + + def flush_paragraph() -> None: + if paragraphs: + parts.append(f"

{_inline_markdown(' '.join(paragraphs))}

") + paragraphs.clear() + + def close_list() -> None: + if list_items: + items = "".join( + f"
  • {_inline_markdown(item)}
  • " for item in list_items + ) + parts.append(f"") + list_items.clear() + + for raw_line in markdown.splitlines(): + line = raw_line.strip() + if not line: + flush_paragraph() + close_list() + continue + if line == "# Changelog": + continue + if line.startswith("## "): + flush_paragraph() + close_list() + parts.append(f"

    {_inline_markdown(line[3:])}

    ") + continue + if line.startswith("### "): + flush_paragraph() + close_list() + parts.append(f"

    {_inline_markdown(line[4:])}

    ") + continue + if line.startswith("- "): + flush_paragraph() + list_items.append(line[2:]) + continue + if list_items and raw_line[:1].isspace(): + list_items[-1] = f"{list_items[-1]} {line}" + continue + close_list() + paragraphs.append(line) + + flush_paragraph() + close_list() + return "\n".join(parts) + + +def _load_changelog() -> str: + try: + return CHANGELOG_PATH.read_text(encoding="utf-8") + except OSError: + return "# Changelog\n\nThe changelog is unavailable in this deployment." + + +CHANGELOG_CONTENT = _render_changelog(_load_changelog()) + +CHANGELOG_HTML = f""" + + + + + Prompt Compression Changelog + + + +
    +
    +
    +

    Changelog

    +

    A dated history of product, quality, and deployment improvements. Build {DEPLOYMENT_VERSION}.

    +
    + +
    +
    + {CHANGELOG_CONTENT} +
    +
    + + +""" diff --git a/app/compression_pipeline.py b/app/compression_pipeline.py index ca8f879..b65df8d 100644 --- a/app/compression_pipeline.py +++ b/app/compression_pipeline.py @@ -433,13 +433,21 @@ def _json_segment_for_candidate( allow_toon: bool = True, leading_context: str = "", ) -> CompressionSegment | None: - if not self._is_medium_large_json(candidate): - return None - parsed = self._parse_json(candidate) if parsed is None: return None + # Size determines whether TOON is worth attempting, not whether valid + # JSON is safe to expose to probabilistic model compression. Preserve + # small JSON verbatim as a protected structured segment. + if not self._is_medium_large_json(candidate): + return CompressionSegment( + text=candidate, + compressible=False, + kind="json", + source_text=candidate, + ) + if ( not allow_toon or self._context_requires_verbatim_json(leading_context) diff --git a/app/compressor.py b/app/compressor.py index 7240f5e..950b8fa 100644 --- a/app/compressor.py +++ b/app/compressor.py @@ -1,6 +1,7 @@ import os import time -from dataclasses import dataclass, field +from collections import Counter +from dataclasses import dataclass, field, replace import logging import math from pathlib import Path @@ -14,6 +15,7 @@ force_tokens_for_text, protected_spans_for_text, ) +from app.structured_json import TaggedJsonTransformResult, transform_tagged_json from app.tenant_profiles import ( DEFAULT_PROFILE_ID, DEFAULT_PROFILE_SOURCE, @@ -2122,6 +2124,79 @@ def _literal_placeholder_for_index(self, index: int) -> str: break return f"[{label}]" + def _compress_tagged_json_values( + self, + text: str, + *, + aggressiveness: float, + profile: TenantCompressionProfile, + mode: str, + latency_budget_ms: float | None, + allow_cpu_model_auto: bool | None, + ) -> TaggedJsonTransformResult: + if " str | None: + if "= result.original_tokens + or result.reduction > profile.json_value_max_reduction + or not self._preserves_protected_span_values( + value, + result.compressed_text, + ) + ): + return None + return result.compressed_text + + return transform_tagged_json( + text, + policy_id=profile.json_compression_policy_id, + value_paths=profile.json_value_compression_paths, + max_values=profile.json_value_max_values, + compress_value=compress_value, + ) + + def _preserves_protected_span_values( + self, + original_text: str, + compressed_text: str, + ) -> bool: + required = Counter( + span.text for span in protected_spans_for_text(original_text) + ) + return all( + compressed_text.count(value) >= count + for value, count in required.items() + ) + def compress( self, text: str, @@ -2146,7 +2221,15 @@ def compress( timings["target_rate_ms"] = _elapsed_ms(phase_start) phase_start = time.perf_counter() - prepared_segments = self.preprocessor.prepare(text) + tagged_json = self._compress_tagged_json_values( + text, + aggressiveness=aggressiveness, + profile=profile, + mode=compression_mode, + latency_budget_ms=latency_budget_ms, + allow_cpu_model_auto=allow_cpu_model_auto, + ) + prepared_segments = self.preprocessor.prepare(tagged_json.text) timings["preprocessing_ms"] = _elapsed_ms(phase_start) preprocessed_text = "".join(segment.text for segment in prepared_segments) @@ -2368,11 +2451,10 @@ def compress( model_gate=model_gate, ) timings["diagnostics_ms"] += _elapsed_ms(phase_start) - warnings = ( - [model_gate.reason] - if model_gate.decision == "skip" and model_gate.reason is not None - else [] - ) + warnings = list(tagged_json.warnings) + if model_gate.decision == "skip" and model_gate.reason is not None: + warnings.append(model_gate.reason) + warnings = list(dict.fromkeys(warnings)) return CompressionResult( compressed_text=compressed_text, diff --git a/app/eval_ui.py b/app/eval_ui.py index d730be7..f65193e 100644 --- a/app/eval_ui.py +++ b/app/eval_ui.py @@ -389,6 +389,7 @@ Compression UI Benchmark Research + Changelog
    diff --git a/app/main.py b/app/main.py index c9f6d93..eeccec5 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse from app.benchmark_ui import BENCHMARK_HTML +from app.changelog_ui import CHANGELOG_HTML from app.compressor import ( COMPRESSION_MODE_DETERMINISTIC, COMPRESSION_MODE_MODEL_AUTO, @@ -575,6 +576,7 @@ Eval Suite Benchmark Research + Changelog
    @@ -1320,6 +1322,11 @@ def benchmark_index() -> HTMLResponse: return HTMLResponse(content=BENCHMARK_HTML, headers=DASHBOARD_EMBED_HEADERS) +@app.get("/changelog", response_class=HTMLResponse) +def changelog_index() -> HTMLResponse: + return HTMLResponse(content=CHANGELOG_HTML, headers=DASHBOARD_EMBED_HEADERS) + + @app.get("/eval/cases", response_model=list[EvalCaseResponse]) def list_eval_cases() -> list[EvalCaseResponse]: return [ @@ -1681,6 +1688,21 @@ def _tenant_profile_from_request( min_rate=None if settings is None else settings.min_rate, force_keep_tokens=() if settings is None else settings.force_keep_tokens, force_drop_phrases=() if settings is None else settings.force_drop_phrases, + json_compression_policy_id=( + None if settings is None else settings.json_compression_policy_id + ), + json_value_compression_paths=( + () if settings is None else settings.json_value_compression_paths + ), + json_value_min_tokens=( + 200 if settings is None else settings.json_value_min_tokens + ), + json_value_max_reduction=( + 0.25 if settings is None else settings.json_value_max_reduction + ), + json_value_max_values=( + 8 if settings is None else settings.json_value_max_values + ), ) diff --git a/app/research_ui.py b/app/research_ui.py index 7d01293..09aba70 100644 --- a/app/research_ui.py +++ b/app/research_ui.py @@ -188,6 +188,7 @@ Compression UI Eval Suite Benchmark + Changelog API Docs
    diff --git a/app/schemas.py b/app/schemas.py index ce178eb..f0f50e6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -42,6 +42,41 @@ class TenantCompressionSettings(BaseModel): default_factory=list, description="Exact compressible boilerplate phrases to drop before model compression.", ) + json_compression_policy_id: str | None = Field( + default=None, + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9_.:-]+$", + description=( + "Tenant-approved policy referenced by " + "blocks. Tags cannot authorize paths that are absent from this profile." + ), + ) + json_value_compression_paths: list[str] = Field( + default_factory=list, + description=( + "Allowlisted JSONPath subset for compressible string leaves, such as " + "$.description or $.comments[*].body. All other values remain exact." + ), + ) + json_value_min_tokens: int = Field( + default=200, + ge=1, + le=1_000_000, + description="Minimum estimated tokens before an allowlisted string is considered.", + ) + json_value_max_reduction: float = Field( + default=0.25, + ge=0.0, + le=1.0, + description="Maximum accepted token reduction for each allowlisted JSON string.", + ) + json_value_max_values: int = Field( + default=8, + ge=1, + le=100, + description="Maximum JSON string values compressed within one request.", + ) class CompressRequest(BaseModel): diff --git a/app/structured_json.py b/app/structured_json.py new file mode 100644 index 0000000..df01ae3 --- /dev/null +++ b/app/structured_json.py @@ -0,0 +1,261 @@ +import json +import re +from dataclasses import dataclass +from typing import Any, Callable + + +OPEN_COMPRESS_JSON_PATTERN = re.compile( + r"[^>]*)>", + re.IGNORECASE, +) +CLOSE_COMPRESS_JSON_PATTERN = re.compile(r"", re.IGNORECASE) +POLICY_ATTRIBUTE_PATTERN = re.compile( + r"\s*policy\s*=\s*(?P['\"])(?P[A-Za-z0-9_.:-]{1,128})" + r"(?P=quote)\s*", + re.IGNORECASE, +) +PATH_KEY_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*") + + +@dataclass(frozen=True) +class TaggedJsonTransformResult: + text: str + compressed_value_count: int = 0 + warnings: tuple[str, ...] = () + + +def transform_tagged_json( + text: str, + *, + policy_id: str | None, + value_paths: tuple[str, ...], + max_values: int, + compress_value: Callable[[str, str], str | None], +) -> TaggedJsonTransformResult: + """Transform explicitly tagged JSON while keeping structure deterministic. + + A matching tenant policy may compress allowlisted string leaves. The + rebuilt JSON is compact and later enters the normal TOON-or-protect path. + Invalid or duplicate-key payloads are wrapped as no-compress content so an + opt-in tag can never make malformed structured content less safe. + """ + if " tuple[str | int, ...] | None: + """Parse a safe JSONPath subset such as $.comments[*].body.""" + value = path.strip() + if value == "$": + return () + if not value.startswith("$."): + return None + + tokens: list[str | int] = [] + cursor = 2 + while cursor < len(value): + key_match = PATH_KEY_PATTERN.match(value, cursor) + if key_match is None: + return None + tokens.append(key_match.group(0)) + cursor = key_match.end() + + if value.startswith("[*]", cursor): + tokens.append("*") + cursor += 3 + + if cursor == len(value): + break + if value[cursor] != ".": + return None + cursor += 1 + + return tuple(tokens) + + +def _transform_value_tree( + value: Any, + *, + path: tuple[str | int, ...], + patterns: list[tuple[str | int, ...]], + remaining: int, + compress_value: Callable[[str, str], str | None], +) -> tuple[Any, int]: + if remaining <= 0: + return value, 0 + + if isinstance(value, str): + if not any(_path_matches(pattern, path) for pattern in patterns): + return value, 0 + path_text = _format_path(path) + compressed = compress_value(path_text, value) + if compressed is None or compressed == value: + return value, 0 + return compressed, 1 + + count = 0 + if isinstance(value, list): + updated_list: list[Any] = [] + for index, item in enumerate(value): + updated, item_count = _transform_value_tree( + item, + path=(*path, index), + patterns=patterns, + remaining=remaining - count, + compress_value=compress_value, + ) + updated_list.append(updated) + count += item_count + return updated_list, count + + if isinstance(value, dict): + updated_dict: dict[str, Any] = {} + for key, item in value.items(): + updated, item_count = _transform_value_tree( + item, + path=(*path, key), + patterns=patterns, + remaining=remaining - count, + compress_value=compress_value, + ) + updated_dict[key] = updated + count += item_count + return updated_dict, count + + return value, 0 + + +def _path_matches( + pattern: tuple[str | int, ...], + path: tuple[str | int, ...], +) -> bool: + if len(pattern) != len(path): + return False + return all(expected == "*" or expected == actual for expected, actual in zip(pattern, path)) + + +def _format_path(path: tuple[str | int, ...]) -> str: + result = "$" + for token in path: + if isinstance(token, int): + result += f"[{token}]" + else: + result += f".{token}" + return result + + +def _decode_json_at(text: str, start: int) -> tuple[Any | None, int, bool]: + duplicate_keys = False + + def collect_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + nonlocal duplicate_keys + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + duplicate_keys = True + result[key] = value + return result + + decoder = json.JSONDecoder(object_pairs_hook=collect_pairs) + try: + parsed, end = decoder.raw_decode(text, start) + except json.JSONDecodeError: + return None, start, False + return parsed, end, duplicate_keys + + +def _protect_verbatim(text: str) -> str: + return f"{text}" diff --git a/app/tenant_profiles.py b/app/tenant_profiles.py index 65e06e0..e6693c4 100644 --- a/app/tenant_profiles.py +++ b/app/tenant_profiles.py @@ -16,6 +16,11 @@ class TenantCompressionProfile: min_rate: float | None = None force_keep_tokens: tuple[str, ...] = () force_drop_phrases: tuple[str, ...] = () + json_compression_policy_id: str | None = None + json_value_compression_paths: tuple[str, ...] = () + json_value_min_tokens: int = 200 + json_value_max_reduction: float = 0.25 + json_value_max_values: int = 8 def build_tenant_profile( @@ -26,11 +31,18 @@ def build_tenant_profile( min_rate: float | None = None, force_keep_tokens: Iterable[str] | None = None, force_drop_phrases: Iterable[str] | None = None, + json_compression_policy_id: str | None = None, + json_value_compression_paths: Iterable[str] | None = None, + json_value_min_tokens: int = 200, + json_value_max_reduction: float = 0.25, + json_value_max_values: int = 8, ) -> TenantCompressionProfile: normalized_tenant_id = _clean_value(tenant_id) or DEFAULT_TENANT_ID normalized_profile_id = _clean_value(profile_id) keep_tokens = _clean_unique(force_keep_tokens or ()) drop_phrases = _clean_unique(force_drop_phrases or ()) + normalized_json_policy_id = _clean_value(json_compression_policy_id) + json_value_paths = _clean_unique(json_value_compression_paths or ()) source = ( API_PROFILE_SOURCE if ( @@ -40,6 +52,8 @@ def build_tenant_profile( or min_rate is not None or keep_tokens or drop_phrases + or normalized_json_policy_id is not None + or json_value_paths ) else DEFAULT_PROFILE_SOURCE ) @@ -59,6 +73,11 @@ def build_tenant_profile( min_rate=min_rate, force_keep_tokens=keep_tokens, force_drop_phrases=drop_phrases, + json_compression_policy_id=normalized_json_policy_id, + json_value_compression_paths=json_value_paths, + json_value_min_tokens=max(1, json_value_min_tokens), + json_value_max_reduction=max(0.0, min(1.0, json_value_max_reduction)), + json_value_max_values=max(1, json_value_max_values), ) diff --git a/app/version.py b/app/version.py index 2eab7a2..986b587 100644 --- a/app/version.py +++ b/app/version.py @@ -1,2 +1,2 @@ -DEPLOYMENT_VERSION = "2026.07.01.110308" -DEPLOYMENT_TIMESTAMP = "2026-07-01T11:03:08-07:00" +DEPLOYMENT_VERSION = "2026.07.12.111729" +DEPLOYMENT_TIMESTAMP = "2026-07-12T11:17:29-07:00" diff --git a/docs/tagged-json-compression.md b/docs/tagged-json-compression.md new file mode 100644 index 0000000..99af8ce --- /dev/null +++ b/docs/tagged-json-compression.md @@ -0,0 +1,253 @@ +# Tagged JSON Compression + +Tagged JSON compression lets a tenant selectively compress approved long text +values inside JSON without exposing the JSON structure to probabilistic model +compression. Keys, types, array positions, and unapproved values remain under +deterministic control. + +Use this feature when a prompt contains structured JSON with large narrative +fields such as descriptions, comments, notes, or summaries. Do not use it for +JSON that must remain byte-identical. + +## Safety invariant + +> A tag can identify a policy, but only the tenant profile can authorize which +> JSON string paths may be compressed. + +An untrusted prompt cannot add paths or increase its own compression limits. +The server compares the tag's policy identifier with the request-scoped tenant +profile and uses only the paths and limits in that profile. + +## XML-style tag schema + +Wrap one JSON object or array in a `compress-json` tag: + +```xml + +{ + "id": "ISSUE-73", + "title": "Customer quota threshold crossed notification", + "description": "A long narrative description...", + "comments": [ + { + "author": "Ada", + "body": "A long narrative comment..." + } + ] +} + +``` + +The supported opening-tag schema is: + +```text +JSON_OBJECT_OR_ARRAY +``` + +Tag requirements: + +- `policy` is required for selective value compression. +- The value must be quoted with single or double quotes. +- It may contain ASCII letters, numbers, `_`, `-`, `.`, and `:` but no spaces. +- Its length must be between 1 and 128 characters. +- The body must contain exactly one valid JSON object or array followed only by + whitespace and the closing tag. +- Multiple tagged blocks may appear in one prompt. +- Nested `compress-json` blocks are not supported. +- Text resembling `` inside a valid JSON string is handled as + string content and does not close the block early. + +The tags are control markup and are removed from the final compressed prompt. + +## Tenant-profile schema + +These settings live inside `tenant_profile` on `/compress`, `/v1/compress`, and +`/v1/messages/compress` requests. + +| Field | Type | Default | Constraints | Meaning | +| --- | --- | --- | --- | --- | +| `json_compression_policy_id` | string or null | `null` | 1-128 characters; `[A-Za-z0-9_.:-]+` | Policy identifier that a tag must reference. | +| `json_value_compression_paths` | array of strings | `[]` | Safe JSONPath subset | String leaves eligible for compression. | +| `json_value_min_tokens` | integer | `200` | 1-1,000,000 | Minimum estimated tokens in an allowlisted string. | +| `json_value_max_reduction` | number | `0.25` | 0.0-1.0 | Maximum accepted token reduction for each string. | +| `json_value_max_values` | integer | `8` | 1-100 | Maximum accepted compressed values across all tagged blocks in one request. | + +Example: + +```json +{ + "profile_id": "tenant_123:issue-v1", + "default_aggressiveness": 0.2, + "min_rate": 0.6, + "json_compression_policy_id": "issue-v1", + "json_value_compression_paths": [ + "$.description", + "$.comments[*].body" + ], + "json_value_min_tokens": 200, + "json_value_max_reduction": 0.25, + "json_value_max_values": 8 +} +``` + +## Supported path syntax + +Paths use a deliberately small JSONPath subset. + +| Pattern | Supported | Meaning | +| --- | --- | --- | +| `$.description` | Yes | Root object's `description` string. | +| `$.metadata.summary` | Yes | Nested `summary` string. | +| `$.comments[*].body` | Yes | `body` string in every item of the `comments` array. | +| `$.comments[0].body` | No | Explicit array indexes are intentionally unsupported. | +| `$..description` | No | Recursive descent is unsupported. | +| `$.items[?(@.type=='note')]` | No | Filters and expressions are unsupported. | +| `$['key with spaces']` | No | Quoted or special-character property access is unsupported. | + +Property names in configured paths must start with a letter or underscore and +may then contain letters, numbers, underscores, or hyphens. Array traversal is +available only through `[*]`. + +Paths must resolve to JSON strings. Objects, arrays, numbers, Booleans, and +null values are never sent for partial model compression. + +## Complete API example + +```json +{ + "tenant_id": "tenant_123", + "tenant_profile": { + "profile_id": "tenant_123:issue-v1", + "json_compression_policy_id": "issue-v1", + "json_value_compression_paths": [ + "$.description", + "$.comments[*].body" + ], + "json_value_min_tokens": 200, + "json_value_max_reduction": 0.25, + "json_value_max_values": 8 + }, + "text": "Review this issue:\n{\"id\":\"ISSUE-73\",\"description\":\"Long narrative...\",\"comments\":[{\"author\":\"Ada\",\"body\":\"Long comment...\"}]}", + "aggressiveness": 0.25, + "mode": "model_auto" +} +``` + +For `/v1/compress`, place `mode` and `aggressiveness` inside +`compression_settings`; the tagged-JSON fields remain inside `tenant_profile`. + +## Processing sequence + +For each tagged block, the service: + +1. Locates the opening tag and parses exactly one JSON value with a JSON-aware + boundary parser. +2. Requires an object or array root and checks for duplicate keys. +3. Verifies that the tag policy matches the tenant policy. +4. Walks the JSON tree in stable source order. +5. Selects only allowlisted string paths, up to `json_value_max_values`. +6. Skips strings smaller than `json_value_min_tokens`. +7. Compresses each eligible string independently using the request's mode and + aggressiveness. +8. Rejects empty results, non-saving results, excessive reductions, and results + that lose protected spans. +9. Restores rejected strings from their original values. +10. Rebuilds compact JSON with correct escaping. +11. Converts the complete object to TOON when safe and beneficial; otherwise it + retains protected JSON. +12. Replaces the entire TOON or JSON segment with a placeholder before any outer + prompt model-compression call, then restores it afterward. + +The model never edits JSON keys, punctuation, types, or object relationships. + +## Model modes + +### `deterministic` + +Allowlisted strings receive only deterministic processing. The rebuilt JSON may +still be compacted or converted to TOON. No LLMLingua call is made. + +### `model_auto` + +Each allowlisted value independently passes the normal auto-mode gates. Values +that fail those gates remain unchanged. The outer prompt then passes its own +model-auto gate after the tagged JSON has become a protected segment. + +### `model_force` + +Each eligible value is submitted for bounded model compression. The surrounding +prompt may require one additional model call. A request can therefore make up +to `json_value_max_values + 1` model calls, subject to segment eligibility and +fallback behavior. + +Use a low `json_value_max_values` setting when latency is important. + +## Acceptance and fallback rules + +An independently compressed string is accepted only when: + +- The output is non-empty. +- Its estimated token count is lower than the original value. +- Its reduction does not exceed `json_value_max_reduction`. +- Every protected URL, email, number, money value, inline-code span, identifier, + constant, and supported constraint span still appears with the required + occurrence count. + +Failure affects only that value; its original string is restored. Other values +may still be accepted. + +## Tag and policy failure behavior + +| Condition | Behavior | Warning | +| --- | --- | --- | +| Policy matches | Apply allowlisted value rules. | None solely for matching. | +| Policy absent or unauthorized | Do not partially compress values; remove the tag and pass valid JSON to normal TOON/protection. | `json_tag_policy_not_authorized:` when an ID is present. | +| Invalid configured path | Ignore that path. | `json_value_path_invalid:` | +| Invalid tagged JSON | Protect its body verbatim. | `json_tag_invalid_json_protected` | +| Duplicate JSON keys | Protect its body verbatim to avoid key collapse. | `json_tag_duplicate_keys_protected` | +| Scalar JSON root | Protect it; selective compression requires an object or array. | `json_tag_root_must_be_object_or_array_protected` | +| Missing or misplaced closing tag | Protect the parsed/body content rather than exposing it. | `json_tag_unclosed_protected` or `json_tag_missing_close_after_json_protected` | + +Warnings appear in the normal response `warnings` array. + +## Output expectations + +The final structured block may be TOON when TOON meets the normal safety and +savings gates, or compact/protected JSON when TOON is not appropriate. The +original `compress-json` wrapper is never included in the result. + +Do not use this feature when a downstream consumer requires byte-identical JSON +or when the prompt presents an exact fixture, schema, template, or tool +exchange. Use `...` for explicitly verbatim text. + +## Recommended policy design + +Prefer narrow semantic allowlists such as: + +```json +{ + "json_value_compression_paths": [ + "$.description", + "$.notes", + "$.comments[*].body" + ] +} +``` + +Avoid paths containing IDs, keys, hashes, enums, URLs, file paths, timestamps, +model names, code, SQL, stack traces, templates, prompts, tool exchanges, +base64, encrypted data, or text whose exact wording is contractual. + +Start with `model_auto`, a `json_value_min_tokens` of at least 200, a maximum +reduction around 0.20-0.25, and no more than 4-8 values per request. Validate +quality on tenant-specific examples before using `model_force`. + +## Operational notes + +- Selective string compression can increase latency because values are handled + independently. +- Tags without a matching tenant policy do not grant extra authority. +- Ordinary untagged valid JSON still follows the global rule: TOON when safe and + beneficial, otherwise protect it verbatim from model compression. +- Structured Markdown such as `fieldName: value` is not JSON and is outside the + scope of this feature. diff --git a/tests/test_json_toon_pipeline.py b/tests/test_json_toon_pipeline.py index 7398815..e7a076d 100644 --- a/tests/test_json_toon_pipeline.py +++ b/tests/test_json_toon_pipeline.py @@ -1,7 +1,10 @@ from typing import Any +import pytest + from app.compression_pipeline import PromptPreprocessor from app.compressor import PromptCompressionService +from app.tenant_profiles import build_tenant_profile from app.toon_adapter import ToonEncodingError from tests.pipeline_helpers import ( RecordingCompressor, @@ -264,7 +267,40 @@ def unavailable_toon_encoder(value: Any) -> str: ) -def test_small_json_does_not_trigger_structured_pipeline(): +@pytest.mark.parametrize("json_value", ['{"ok": true}', "[]", "{}"]) +def test_small_json_is_protected_verbatim_without_attempting_toon( + json_value: str, +): + def unexpected_toon_encoder(_value: Any) -> str: + raise AssertionError("small JSON should not be sent to the TOON encoder") + + compressor = RecordingCompressor() + service = PromptCompressionService() + service._compressor = compressor + service.preprocessor = PromptPreprocessor( + toon_encoder=unexpected_toon_encoder, + min_json_chars=100, + min_json_lines=4, + min_toon_savings=0.0, + ) + service.min_segment_chars = 1 + service.min_segment_tokens = 1 + text = f"Please review {json_value} after." + + result = service.compress(text, aggressiveness=0.25) + + assert compressor.inputs == ["Please review __CK_KEEP_0000__ after."] + assert result.compressed_text == f"Review {json_value} after." + assert [section.kind for section in result.output_sections] == [ + "prose", + "json", + "prose", + ] + assert result.output_sections[1].protected is True + assert result.output_sections[1].compressed is False + + +def test_model_auto_protects_small_json_when_model_gate_runs(): compressor = RecordingCompressor() service = PromptCompressionService() service._compressor = compressor @@ -276,11 +312,163 @@ def test_small_json_does_not_trigger_structured_pipeline(): ) service.min_segment_chars = 1 service.min_segment_tokens = 1 + service.device = "cuda" + service.model_auto_enabled = True + service.min_model_candidate_tokens = 1 + service.min_model_incremental_savings_tokens = 0 + service.min_model_incremental_reduction = 0.0 + service.max_model_projected_latency_ms = 1000.0 + service.max_protected_density = 1.0 + service.max_structured_density = 1.0 + service.skip_model_if_deterministic_reduction_gte = 1.0 + service.gpu_p50_fixed_overhead_ms = 1.0 + service.gpu_p50_llmlingua_chunk_ms = 1.0 + service.gpu_p50_token_estimate_ms = 1.0 text = 'Please review {"ok": true} after.' - service.compress(text, aggressiveness=0.25) + result = service.compress( + text, + aggressiveness=0.25, + mode="model_auto", + ) + + assert compressor.inputs == ["Please review __CK_KEEP_0000__ after."] + assert result.compressed_text == 'Review {"ok": true} after.' + assert result.diagnostics is not None + assert result.diagnostics.model_gate_decision == "run" + + +def test_tagged_json_compresses_allowlisted_strings_then_protects_structure(): + compressor = RecordingCompressor() + service = PromptCompressionService() + service._compressor = compressor + service.preprocessor = PromptPreprocessor( + toon_encoder=fake_toon_encoder, + min_json_chars=10_000, + min_json_lines=100, + ) + service.min_segment_chars = 1 + service.min_segment_tokens = 1 + profile = build_tenant_profile( + tenant_id="tenant_123", + json_compression_policy_id="issue-v1", + json_value_compression_paths=["$.description"], + json_value_min_tokens=1, + json_value_max_reduction=0.9, + ) + text = ( + "Please review tagged data.\n" + '' + '{"id":"ISSUE-73","title":"Please review exact title",' + '"description":"Please review this detailed narrative before launch."}' + "\n" + "Please review after." + ) + + result = service.compress( + text, + aggressiveness=0.25, + tenant_profile=profile, + mode="model_force", + ) + + assert compressor.inputs[0] == ( + "Please review this detailed narrative before launch." + ) + assert compressor.inputs[-1] == ( + "Please review tagged data.\n__CK_KEEP_0000__\nPlease review after." + ) + assert "' + f'{{"description":"{original_value}"}}' + "
    " + ) + + result = service.compress( + text, + aggressiveness=0.25, + tenant_profile=profile, + mode="model_force", + ) + + assert f'"description":"{original_value}"' in result.compressed_text + - assert compressor.inputs == [text] +def test_tagged_json_selective_values_are_rebuilt_before_toon_protection(): + encoded_values: list[Any] = [] + + def recording_toon_encoder(value: Any) -> str: + encoded_values.append(value) + return "i{id,d}:\n ISSUE-73,Review" + + compressor = RecordingCompressor() + service = PromptCompressionService() + service._compressor = compressor + service.preprocessor = PromptPreprocessor( + toon_encoder=recording_toon_encoder, + min_json_chars=1, + min_json_lines=1, + min_toon_savings=0.0, + ) + service.min_segment_chars = 1 + service.min_segment_tokens = 1 + profile = build_tenant_profile( + tenant_id="tenant_123", + json_compression_policy_id="issue-v1", + json_value_compression_paths=["$.description"], + json_value_min_tokens=1, + json_value_max_reduction=0.9, + ) + text = ( + "Please review before.\n" + '' + '{"id":"ISSUE-73","description":"Please review narrative"}' + "\n" + "Please review after." + ) + + result = service.compress( + text, + aggressiveness=0.25, + tenant_profile=profile, + mode="model_force", + ) + + assert encoded_values == [ + {"id": "ISSUE-73", "description": "Review narrative"} + ] + assert "i{id,d}" in result.compressed_text + assert compressor.inputs[-1] == ( + "Please review before.\n__CK_KEEP_0000__\nPlease review after." + ) + assert "ISSUE-73" not in compressor.inputs[-1] def test_llm_tool_call_json_is_not_toonified(): diff --git a/tests/test_main.py b/tests/test_main.py index 64361f4..359bd44 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -141,6 +141,7 @@ def test_index_returns_prompt_compression_ui(): assert "Eval Suite" in body assert 'href="/benchmark"' in body assert 'href="/research"' in body + assert 'href="/changelog"' in body assert "Dropped Words Highlighted" in body assert "Diagnostic Logs" in body assert "JSON compressed to TOON" in body @@ -250,6 +251,7 @@ def test_eval_index_returns_eval_ui(): assert "Run Selected" in body assert 'href="/benchmark"' in body assert 'href="/research"' in body + assert 'href="/changelog"' in body assert "/eval/run" in body @@ -259,6 +261,7 @@ def test_benchmark_index_returns_benchmark_page(): assert "Performance Benchmark" in body assert 'href="/eval"' in body + assert 'href="/changelog"' in body assert "include_diagnostics" in body assert "Download Raw JSONL" in body assert "LLMLingua p50" in body @@ -284,6 +287,7 @@ def test_research_index_returns_research_page(): assert "Prompt Compression Research" in body assert 'href="/benchmark"' in body + assert 'href="/changelog"' in body assert "LLMLingua-2 BERT-base" in body assert "PCToolkit Assessment" in body assert "not as a production runtime dependency" in body @@ -292,6 +296,18 @@ def test_research_index_returns_research_page(): assert "Hugging Face PEFT" in body +def test_changelog_index_returns_project_history(): + response = main.changelog_index() + body = response.body.decode() + + assert "Prompt Compression Changelog" in body + assert "Added JSON protection and selective text compression based on tenant schema" in body + assert "Added TOON and deterministic whitespace pipelines" in body + assert 'href="/eval"' in body + assert 'href="/benchmark"' in body + assert 'href="/research"' in body + + def test_eval_cases_endpoint_returns_fixture_cases(): response = main.list_eval_cases() @@ -488,6 +504,14 @@ def test_compress_uses_request_supplied_tenant_profile(monkeypatch): min_rate=0.6, force_keep_tokens=["AcmeTerm", "AcmeTerm", " SKU-77 "], force_drop_phrases=["Reusable preamble", ""], + json_compression_policy_id="issue-v1", + json_value_compression_paths=[ + "$.description", + "$.comments[*].body", + ], + json_value_min_tokens=120, + json_value_max_reduction=0.2, + json_value_max_values=4, ), text="Prompts are code.", ) @@ -502,6 +526,14 @@ def test_compress_uses_request_supplied_tenant_profile(monkeypatch): assert profile.min_rate == 0.6 assert profile.force_keep_tokens == ("AcmeTerm", "SKU-77") assert profile.force_drop_phrases == ("Reusable preamble",) + assert profile.json_compression_policy_id == "issue-v1" + assert profile.json_value_compression_paths == ( + "$.description", + "$.comments[*].body", + ) + assert profile.json_value_min_tokens == 120 + assert profile.json_value_max_reduction == 0.2 + assert profile.json_value_max_values == 4 assert response.tenant_id == "tenant_123" assert response.compression_profile == "tenant_123:v1" assert response.compression_profile_source == "api" diff --git a/tests/test_structured_json.py b/tests/test_structured_json.py new file mode 100644 index 0000000..f3532ca --- /dev/null +++ b/tests/test_structured_json.py @@ -0,0 +1,137 @@ +import json + +from app.structured_json import parse_value_path, transform_tagged_json + + +def test_parse_value_path_supports_keys_and_array_wildcards(): + assert parse_value_path("$.description") == ("description",) + assert parse_value_path("$.comments[*].body") == ( + "comments", + "*", + "body", + ) + assert parse_value_path("comments.body") is None + assert parse_value_path("$.comments[0].body") is None + + +def test_matching_policy_compresses_only_allowlisted_string_values(): + source = ( + '' + '{"id":"ISSUE-73","description":"Long narrative",' + '"comments":[{"author":"Ada","body":"Detailed comment"}]}' + "" + ) + + result = transform_tagged_json( + source, + policy_id="issue-v1", + value_paths=("$.description", "$.comments[*].body"), + max_values=8, + compress_value=lambda path, value: f"compressed:{path}:{value}", + ) + + parsed = json.loads(result.text) + assert parsed["id"] == "ISSUE-73" + assert parsed["comments"][0]["author"] == "Ada" + assert parsed["description"] == ( + "compressed:$.description:Long narrative" + ) + assert parsed["comments"][0]["body"] == ( + "compressed:$.comments[0].body:Detailed comment" + ) + assert result.compressed_value_count == 2 + + +def test_policy_mismatch_removes_tag_but_does_not_compress_values(): + body = '{"description":"Long narrative"}' + result = transform_tagged_json( + f'{body}', + policy_id="issue-v1", + value_paths=("$.description",), + max_values=8, + compress_value=lambda _path, _value: "must not run", + ) + + assert result.text == body + assert result.compressed_value_count == 0 + assert result.warnings == ("json_tag_policy_not_authorized:other-v1",) + + +def test_invalid_and_duplicate_key_tagged_json_is_protected_verbatim(): + invalid = transform_tagged_json( + '{"broken":}', + policy_id="p", + value_paths=("$.broken",), + max_values=8, + compress_value=lambda _path, value: value, + ) + duplicate = transform_tagged_json( + '{"name":"old","name":"new"}', + policy_id="p", + value_paths=("$.name",), + max_values=8, + compress_value=lambda _path, value: value, + ) + + assert invalid.text == '{"broken":}' + assert invalid.warnings == ("json_tag_invalid_json_protected",) + assert duplicate.text == ( + '{"name":"old","name":"new"}' + ) + assert duplicate.warnings == ("json_tag_duplicate_keys_protected",) + + +def test_max_values_limits_selective_compression(): + source = ( + '' + '{"comments":[{"body":"one"},{"body":"two"}]}' + "" + ) + result = transform_tagged_json( + source, + policy_id="p", + value_paths=("$.comments[*].body",), + max_values=1, + compress_value=lambda _path, value: value.upper(), + ) + + assert json.loads(result.text) == { + "comments": [{"body": "ONE"}, {"body": "two"}] + } + assert result.compressed_value_count == 1 + + +def test_tag_like_text_inside_json_string_does_not_end_block_early(): + source = ( + '' + '{"description":"Literal text remains inside the value",' + '"id":"A-1"}' + "" + ) + result = transform_tagged_json( + source, + policy_id="p", + value_paths=("$.description",), + max_values=1, + compress_value=lambda _path, value: value.replace("Literal", "Short"), + ) + + assert json.loads(result.text) == { + "description": "Short text remains inside the value", + "id": "A-1", + } + + +def test_scalar_json_root_is_protected_instead_of_selectively_compressed(): + result = transform_tagged_json( + '"long string"', + policy_id="p", + value_paths=("$",), + max_values=1, + compress_value=lambda _path, _value: "short", + ) + + assert result.text == '"long string"' + assert result.warnings == ( + "json_tag_root_must_be_object_or_array_protected", + )