from __future__ import annotations
import functools
import html
import importlib.util
import os
import socket
import sys
from contextlib import closing
from pathlib import Path
from typing import Any
import gradio as gr
import nltk
import psutil
import torch
from transformers import AutoModel
from example_inputs import EXAMPLE_INPUTS
def _ensure_nltk_punkt_resources() -> None:
"""Download punkt resources on first run so inference never fails."""
for resource in ("punkt", "punkt_tab"):
try:
nltk.data.find(f"tokenizers/{resource}")
except LookupError:
nltk.download(resource, quiet=True)
DEFAULT_MODEL = "hotchpotch/open-provence-reranker-xsmall-v1"
DEFAULT_THRESHOLD = 0.1
DEFAULT_BATCH_SIZE = 8
MODEL_SCRIPT_ENV = "OPEN_PROVENCE_MODELING_SCRIPT_PATH"
DEVICE_HINT = os.getenv("OPEN_PROVENCE_INFER_DEVICE")
def _resolve_device() -> torch.device:
if DEVICE_HINT:
try:
return torch.device(DEVICE_HINT)
except (TypeError, RuntimeError):
pass
if torch.cuda.is_available():
return torch.device("cuda")
if torch.backends.mps.is_available(): # type: ignore[attr-defined]
return torch.device("mps")
return torch.device("cpu")
INFERENCE_DEVICE = _resolve_device()
_ensure_nltk_punkt_resources()
def _discover_local_ips() -> list[str]:
"""Return IPv4 addresses reachable from the local network."""
addresses: set[str] = set()
try:
interfaces = psutil.net_if_addrs()
except Exception: # pragma: no cover - platform specific
interfaces = {}
for entries in interfaces.values():
for entry in entries:
if entry.family == socket.AF_INET:
ip = entry.address
if ip and not ip.startswith("127."):
addresses.add(ip)
if not addresses:
try:
fallback_ip = socket.gethostbyname(socket.gethostname())
except OSError:
fallback_ip = None
if fallback_ip and not fallback_ip.startswith("127."):
addresses.add(fallback_ip)
return sorted(addresses)
def _find_free_port() -> int:
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
sock.bind(("", 0))
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return int(sock.getsockname()[1])
def _resolve_server_port(preferred: int) -> tuple[int, bool]:
"""Return an available port, optionally falling back from the preferred value."""
if preferred <= 0:
return _find_free_port(), True
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
try:
sock.bind(("", preferred))
except OSError:
return _find_free_port(), True
else:
return preferred, False
@functools.lru_cache(maxsize=4)
def _load_model_cached(model_identifier: str) -> Any:
resolved_identifier = model_identifier.strip() or DEFAULT_MODEL
script_path = os.getenv(MODEL_SCRIPT_ENV)
if script_path:
custom_script = Path(script_path).expanduser()
if not custom_script.exists():
raise gr.Error(f"Custom modeling script not found: {custom_script}")
module_name = f"open_provence_custom_{custom_script.stem}"
spec = importlib.util.spec_from_file_location(module_name, custom_script)
if spec is None or spec.loader is None:
raise gr.Error(f"Unable to load module from {custom_script}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module) # type: ignore[call-arg]
model_cls = getattr(module, "OpenProvenceModel", None)
if model_cls is None:
raise gr.Error("OpenProvenceModel class not found in custom script")
model = model_cls.from_pretrained(resolved_identifier, trust_remote_code=True)
else:
model = AutoModel.from_pretrained(resolved_identifier, trust_remote_code=True)
try:
model = model.to(device=INFERENCE_DEVICE) # type: ignore[attr-defined]
except AttributeError:
pass
return model
def _format_summary(
*,
model_name: str,
input_chars: int,
output_chars: int,
score: float | None,
compression: float | None,
duration: float | None,
) -> str:
compression_percent = f"{compression:.1f}%" if isinstance(compression, (int, float)) else "N/A"
score_str = f"{score:.4f}" if isinstance(score, (int, float)) else "N/A"
duration_str = f"{duration:.2f}s" if isinstance(duration, (int, float)) else "N/A"
return (
f"**Model**: `{model_name}`\n"
f"- Characters: {input_chars} → {output_chars}\n"
f"- Score: {score_str}\n"
f"- Compression: {compression_percent}\n"
f"- Processing time: {duration_str}"
)
def _flatten_first_sequence(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, (list, tuple)):
if value and all(isinstance(item, (list, tuple)) for item in value):
return list(value[0])
return list(value)
return [value]
def _render_sentence_scores_html(
probabilities: list[float],
kept_sentences: list[str],
removed_sentences: list[str],
threshold: float,
) -> str:
if not probabilities:
return (
'
'
"Sentence-level scores unavailable for this run."
"
"
)
keep_flags = [float(prob) > threshold for prob in probabilities]
keep_index = 0
remove_index = 0
rendered: list[tuple[str, float, bool]] = []
for keep_flag, prob in zip(keep_flags, probabilities):
if keep_flag:
sentence = kept_sentences[keep_index] if keep_index < len(kept_sentences) else ""
keep_index += 1
else:
sentence = (
removed_sentences[remove_index] if remove_index < len(removed_sentences) else ""
)
remove_index += 1
rendered.append((sentence, float(prob), keep_flag))
style = """
""".strip()
blocks: list[str] = []
for sentence, prob, keep_flag in rendered:
score_value = max(0.0, min(float(prob), 1.0))
score_str = f"{score_value:.4f}"
status_class = "keep" if keep_flag else "drop"
safe_text = html.escape(sentence.strip() or "(empty sentence)")
blocks.append(
f''
f'
{score_str}'
f'
{safe_text}
'
"
"
)
return style + '' + "".join(blocks) + "
"
def run_inference(
model_identifier: str,
threshold_value: float,
question: str,
title: str,
text: str,
*,
progress: gr.Progress = gr.Progress(track_tqdm=False),
) -> tuple[str, str, str]:
question_value = (question or "").strip()
text_value = (text or "").strip()
title_value = (title or "").strip() or None
if not question_value:
raise gr.Error("Please enter a question.")
if not text_value:
raise gr.Error("Please provide the context text.")
model_name = (model_identifier or DEFAULT_MODEL).strip() or DEFAULT_MODEL
progress(0.0, desc="Loading model…")
try:
model = _load_model_cached(model_name)
except Exception as exc: # pragma: no cover - user provided paths can fail
raise gr.Error(f"Failed to load the model: {exc}") from exc
clamped_threshold = min(max(float(threshold_value), 0.0), 1.0)
process_kwargs: dict[str, Any] = {
"question": question_value,
"context": text_value,
"threshold": clamped_threshold,
"batch_size": DEFAULT_BATCH_SIZE,
"language": "auto",
"return_sentence_metrics": True,
"return_sentence_texts": True,
}
if title_value:
process_kwargs["title"] = title_value
progress(0.5, desc="Running inference…")
with torch.inference_mode():
try:
raw = model.process(**process_kwargs) # type: ignore[attr-defined]
except Exception as exc: # pragma: no cover - external code
raise gr.Error(f"An error occurred during inference: {exc}") from exc
summary = _format_summary(
model_name=model_name,
input_chars=len(text_value),
output_chars=len(_extract_string(raw.get("pruned_context"))),
score=_extract_float(raw.get("reranking_score")),
compression=_extract_float(raw.get("compression_rate")),
duration=_extract_total_seconds(raw.get("timing"), raw.get("performance_trace")),
)
probabilities = [
float(value)
for value in _flatten_first_sequence(raw.get("sentence_probabilities"))
if isinstance(value, (int, float))
]
kept_sentences = [
str(value) for value in _flatten_first_sequence(raw.get("kept_sentences")) if value
]
removed_sentences = [
str(value) for value in _flatten_first_sequence(raw.get("removed_sentences")) if value
]
sentence_scores_html = _render_sentence_scores_html(
probabilities=probabilities,
kept_sentences=kept_sentences,
removed_sentences=removed_sentences,
threshold=clamped_threshold,
)
progress(1.0, desc="Completed")
return summary, _extract_string(raw.get("pruned_context")), sentence_scores_html
def _extract_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _extract_string(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (list, tuple)):
parts = [_extract_string(v) for v in value if v]
return "\n".join(part for part in parts if part)
return str(value)
def _extract_total_seconds(timing: Any, performance_trace: Any) -> float | None:
if isinstance(timing, dict):
maybe = _extract_float(timing.get("total_seconds"))
if maybe is not None:
return maybe
if isinstance(performance_trace, dict):
return _extract_float(performance_trace.get("total_seconds"))
return None
def build_interface() -> gr.Blocks:
with gr.Blocks(title="OpenProvence Context Pruner & Reranker Demo") as demo:
gr.Markdown(
"""
## ✂️ Open Provence Context Pruner & Reranker
This demo showcases the
[OpenProvence](https://github.com/hotchpotch/open_provence)
project, which applies the [Provence](https://arxiv.org/abs/2501.16214)
approach to remove question-irrelevant context
while simultaneously computing reranker scores.
Modern AI agents and context-engineering pipelines often recurse through
large batches of search results.
Irrelevant passages inflate the tokens we send to an LLM.
By inserting a compact pre-processing model such as Provence,
we pass along only the evidence that matters before the LLM generates an answer.
OpenProvence is trained on public datasets with fully open training and inference code,
and the weights are released under the MIT License.
### Demo Highlights 🚀
- ⚡️ [30M-parameter compact model][hf-model]
handles both English and Japanese on CPU -- lightning-fast on GPU.
- ⚙️ Additional model families are available.
Swap the model ID to try a different checkpoint.
[hf-model]: https://huggingface.co/hotchpotch/open-provence-reranker-xsmall-v1
"""
)
with gr.Accordion("Help (click to expand)", open=False):
gr.Markdown(
"""
- **Model** 🔗: Provide a local path or Hugging Face model ID
that points to an OpenProvence checkpoint.
- **threshold (recommend: 0.05-0.5)**: Range 0.00-1.00.
Higher values prune more aggressively. Lower values favor recall.
Adjust to suit your task.
- **Title**: Supplying a concise title can improve pruning quality
when the context is long.
- Sentence segmentation and language detection run in-model --
no manual tuning needed.
- For implementation notes and evaluation results, see the
[Open Provence documentation](https://github.com/hotchpotch/open_provence).
"""
)
with gr.Row():
model_input = gr.Textbox(
label="Model",
value=DEFAULT_MODEL,
placeholder="e.g. hotchpotch/open-provence-reranker-xsmall-v1",
)
threshold_slider = gr.Slider(
label="threshold (recommend: 0.05-0.5)",
minimum=0.0,
maximum=1.0,
step=0.01,
value=DEFAULT_THRESHOLD,
)
with gr.Group():
question_input = gr.Textbox(
label="Question (required)",
lines=1,
)
title_input = gr.Textbox(
label="Title (optional)",
lines=1,
)
text_input = gr.Textbox(
label="Context (required, long passages supported)",
lines=12,
)
gr.Examples(
examples=[[item.question, item.title, item.text] for item in EXAMPLE_INPUTS],
inputs=[question_input, title_input, text_input],
label="Sample input",
examples_per_page=len(EXAMPLE_INPUTS),
)
run_button = gr.Button("Run inference", variant="primary")
with gr.Tabs():
with gr.Tab("Pruned Context"):
summary_output = gr.Markdown(label="Summary")
pruned_output = gr.Textbox(label="Pruned Text", lines=12)
with gr.Tab("Sentence Scores"):
sentence_scores_output = gr.HTML(
label="Sentence Scores",
value="Run inference to see per-sentence scores.",
)
run_button.click(
fn=run_inference,
inputs=[
model_input,
threshold_slider,
question_input,
title_input,
text_input,
],
outputs=[summary_output, pruned_output, sentence_scores_output],
)
return demo
demo = build_interface()
if __name__ == "__main__":
requested_port = int(os.getenv("GRADIO_PORT", os.getenv("PORT", "7860")))
port_value, port_was_adjusted = _resolve_server_port(requested_port)
local_ips = _discover_local_ips()
if local_ips:
print("Local network URLs:")
for ip in local_ips:
print(f" http://{ip}:{port_value}")
else:
print("No external IPv4 address detected; falling back to localhost only.")
if port_was_adjusted and port_value != requested_port:
print(f"Port {requested_port} unavailable; switched to {port_value}.")
demo.launch(server_name="0.0.0.0", server_port=port_value, debug=True)