"""Small, bounded generic taxonomy demo; no model calls or filesystem input.""" import json from time import perf_counter import gradio as gr from range_curate import CurationEngine, CurationTaxonomy TAXONOMY = '''version: 1 name: Clinical research example rules: - label: clinical_trials include: [randomized trial, phase 3 trial] exclude: [mouse model] - label: oncology include: [lung cancer, cancer immunotherapy] exclude: [] ''' DOCUMENTS = json.dumps([ 'A randomized trial evaluated a treatment for lung cancer.', 'A mouse model was discussed alongside a randomized trial.', 'A city council approved new bicycle parking.' ], indent=2) GUIDE = '''## Build a curated dataset with your own taxonomy **Python package coming soon.** You can try the hosted demo now or build from source. [Read the code README for installation, taxonomy YAML, and streaming-batch examples](https://huggingface.co/spaces/Brianferrell787/range/blob/main/PACKAGE_README.md). 1. **Define inclusion.** Write down what belongs, what does not, and ambiguous cases. 2. **Name your labels.** Add phrases and spelling variants; use clear, stable labels. 3. **Test a small labeled sample.** Include positive cases, near misses, exclusions, abbreviations, and documents with multiple topics. Review false positives and false negatives before processing the full collection. 4. **Freeze and run.** Save the YAML, taxonomy fingerprint, package version, and input identity. Use bounded batches for large collections; save retained row IDs. 5. **Review and iterate.** Compare revisions on a separate held-out sample. The generic API keeps a label when **any include phrase** matches and **no exclude phrase** for that label matches anywhere in the document. Labels are independent. Offsets refer to the original text, in Unicode code points. This is normalized literal matching: it does not infer negation, diagnoses, intent, or scientific validity. The biomedical wording is a toy example, not a validated biomedical taxonomy. ### Ask an AI agent to help draft your taxonomy Copy this prompt into your own agent, then review its output: > Help me draft a RANGE version-1 taxonomy for [dataset purpose]. First clarify > inclusion, exclusion, and ambiguous cases. Use only keys version, name, rules; > each rule has label, include, and optional exclude. Phrase lists contain strings. > Suggest aliases and collision risks. Explain that exclusions veto a label across > the whole document. Provide separate synthetic positive, negative, and borderline > examples with expected labels. Do not claim clinical validity or measured accuracy. > Keep proposed rules separate from approved rules. Do not use evaluation documents > to tune the taxonomy. Return YAML and a review checklist; do not execute it. Agents help author and test rules; no agent or model runs inside this demo. Use representative, appropriately shareable examples and human/domain review. ### Turn a text collection into a dataset you can use Search helps you find material for a question. Curation answers a different question: **Which documents belong in the dataset I want to build?** Start with your collection and define what qualifies: topics, phrases, labels, and exclusions. RANGE applies those rules across your text, records keep/drop decisions, and shows the matched evidence. Save the selected records for analysis, model training, evaluation, or a later enrichment stage. For example, a research team could use a reviewed taxonomy to select clinical-trial reports from a mixed document collection. The result is a reusable dataset with a record of why each document was selected. Save the taxonomy version and input IDs so you can rerun the same policy or compare it with a revised one. RANGE is built around **bulk selection, explicit rules, and traceable decisions**. It does not generate answers or rank documents by semantic similarity. ### Optional semantic enrichment: future work NER, GLiNER and other models could contribute entity observations or review signals. They are not integrated here. Their model versions, thresholds, uncertainty and costs need separate validation and benchmarks. A lexical prefilter can lose relevant documents; it is not automatically a recall-preserving gate for a semantic stage. The General Economy tabs demonstrate one specialized taxonomy and its market relationships. Those relationships and historical benchmark numbers are not requirements or performance promises for your custom taxonomy. ''' def processing_timing(elapsed, count): """Describe this CPU invocation, not a sustained-throughput benchmark.""" milliseconds = elapsed * 1000 duration = f'{milliseconds:,.3f} ms' if milliseconds >= 0.001 else '<0.001 ms' rate = count / elapsed if elapsed > 0 else None headline = f'Processed {count:,} record{"s" if count != 1 else ""} in {duration} on CPU' if count > 1 and rate is not None: headline += f' · {rate:,.0f} records/s for this batch' scope = ('Includes matching, decision evidence and engine setup when needed. ' 'Excludes input validation, queue/network wait and display rendering. ' 'Small demo runs are not sustained bulk benchmarks.') return {'processing_ms': milliseconds, 'records_per_second': rate, 'backend': 'cpu', 'timing_scope': scope}, f'**{headline}**\n\n{scope}' def curate_custom(taxonomy_yaml, documents_json): for value, limit, byte_limit, name in [(taxonomy_yaml, 12000, 48000, 'Taxonomy'), (documents_json, 202000, 810000, 'Documents')]: if not isinstance(value, str) or len(value) > limit or len(value.encode('utf8')) > byte_limit: raise gr.Error(f'{name} input exceeds the demo limit.') try: # Hosted input uses a small, alias-free YAML subset; local API accepts normal safe YAML. import yaml if any(isinstance(t, (yaml.tokens.AliasToken, yaml.tokens.AnchorToken)) for t in yaml.scan(taxonomy_yaml)): raise ValueError('YAML anchors and aliases are not supported in this demo') taxonomy = CurationTaxonomy.from_yaml(taxonomy_yaml) phrases = [p for r in taxonomy.rules for p in (*r.include, *r.exclude)] if len(taxonomy.rules) > 32 or len(phrases) > 256 or any(len(p) > 160 for p in phrases): raise ValueError('Use at most32 labels,256 phrases,160 characters per phrase') docs = json.loads(documents_json) if not isinstance(docs, list) or not 1 <= len(docs) <= 100 or any(not isinstance(d, str) for d in docs): raise ValueError('Provide a JSON array containing1–100 text strings') if any(len(d) > 20000 or len(d.encode('utf8')) > 80000 for d in docs) or sum(map(len, docs)) > 200000 or sum(len(d.encode('utf8')) for d in docs) > 800000: raise ValueError('Document size exceeds the per-document or batch limit') started = perf_counter() engine = CurationEngine(taxonomy, workers=1) rows = engine.screen_batch(docs, evidence=True, max_evidence=50) elapsed = perf_counter() - started except (ValueError, TypeError, yaml.YAMLError, RecursionError) as exc: raise gr.Error('Invalid taxonomy or documents. Check the documented schema and size limits.') from exc timing, timing_text = processing_timing(elapsed, len(rows)) try: range_seconds, basic_seconds, matches = engine._scanner.compare_matching(docs) except ValueError: timing_text += ('\n\nMatching comparison unavailable: the match limit was exceeded ' 'or the two matchers did not agree. Curation results are still shown.') else: timing['matching_comparison'] = { 'range_ms': range_seconds * 1000, 'basic_search_ms': basic_seconds * 1000, 'verified_matches': matches, 'repeats': 5, 'statistic': 'median', 'scope': 'Prepared-text matching, boundary checks and identical match-position output; CPU, one thread', } timing_text = ( '### Search time on your documents\n\n' '| Matcher | Search time |\n| --- | ---: |\n' f'| RANGE | {range_seconds * 1000:,.6f} ms |\n' f'| Basic search (one phrase at a time) | {basic_seconds * 1000:,.6f} ms |\n\n' f'**{matches:,} identical matches verified across {len(docs):,} records.** ' 'Both run in native code on one CPU thread, using the same prepared text, ' 'phrases and word-boundary rules. Times include finding matches and collecting ' 'their positions; they exclude normalization, matcher setup, labels and evidence formatting. ' 'Median of five warm runs with alternating order. Small-input timings vary; ' 'this is a basic substring-search baseline, not an indexed search engine.\n\n' + timing_text ) return [[r['row_index'], r['keep'], ', '.join(r['labels'])] for r in rows], { **timing, 'documents': len(rows), 'kept': sum(r['keep'] for r in rows), 'taxonomy_fingerprint': taxonomy.fingerprint, 'note': 'Rule matches only. Evidence capped at 50 spans per document; decisions examine all matches.'}, rows, timing_text def build_curation_tabs(): with gr.Tab('Curate your text'): gr.Markdown('### Your taxonomy. Your curated dataset. At speed.\nEdit this illustrative taxonomy or replace it with your own domain. This bounded demo accepts up to 100 documents; the package supports streaming batches.') with gr.Row(): tax = gr.Code(value=TAXONOMY, language='yaml', label='Your taxonomy (YAML)', lines=13) docs = gr.Textbox(value=DOCUMENTS, label='Documents (JSON array of text strings)', lines=13, max_length=202000) run = gr.Button('Curate dataset', variant='primary') timing = gr.Markdown() table = gr.Dataframe(headers=['row_index', 'keep', 'labels'], interactive=False, label='Selection decisions') metrics = gr.JSON(label='Curation summary') evidence = gr.JSON(label='Matched evidence and original offsets') run.click(curate_custom, [tax, docs], [table, metrics, evidence, timing], api_name='curate', concurrency_limit=1, concurrency_id='range-cpu') with gr.Tab('Build your taxonomy'): gr.Markdown(GUIDE)