findingmodel


Namefindingmodel JSON
Version 0.5.0 PyPI version JSON
download
home_pageNone
SummaryDefinition and tools for Open Imaging Finding Models
upload_time2025-11-03 20:24:40
maintainerNone
docs_urlNone
authorTarik Alkasab, Vijay Dawal
requires_python>=3.11
licenseNone
keywords finding model common data element medical imaging data model radiology
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            # `findingmodel` Package

A Python library for managing Open Imaging Finding Models - structured data models used to describe medical imaging findings in radiology reports. The library provides tools for creating, converting, and managing these finding models with AI-powered features for medical ontology integration.

## Features

- **Finding Model Management**: Create and manage structured medical finding models with attributes
- **AI-Powered Tools**: Generate finding descriptions, synonyms, and detailed information using OpenAI and Perplexity
- **Medical Ontology Integration**: Search and match concepts across multiple backends:
  - **BioOntology API**: Access to 800+ medical ontologies including SNOMED-CT, ICD-10, LOINC
  - **DuckDB Search**: High-performance vector and full-text search with HNSW indexing
- **Protocol-Based Architecture**: Flexible backend support with automatic parallel execution
- **Finding Model Index**: Fast lookup and search across finding model definitions
- **Anatomic Location Discovery**: Two-agent AI system for finding relevant anatomic locations

## Installation

```bash
pip install findingmodel
```

## Configuration

Configure the library by creating a `.env` file in your project root. See `.env.sample` for all available options.

### API Keys (Required/Optional by Feature)

Different features require different API keys:

| Feature | Required Key | Purpose |
|---------|--------------|---------|
| **Core AI Features** | `OPENAI_API_KEY` | Generate descriptions, synonyms, create models from markdown |
| **Detailed Finding Info** | `PERPLEXITY_API_KEY` | Add citations and detailed descriptions (requires OpenAI key too) |
| **800+ Medical Ontologies** | `BIOONTOLOGY_API_KEY` | Access BioOntology.org for SNOMED-CT, ICD-10, LOINC, etc. |

```bash
# Required for most features
OPENAI_API_KEY=your_key_here

# Optional - only needed for add_details_to_info()
PERPLEXITY_API_KEY=your_key_here

# Optional - only needed for BioOntology backend in ontology searches
BIOONTOLOGY_API_KEY=your_key_here
```

**Note:** The Index and anatomic location search work without any API keys (DuckDB backend). OpenAI is only needed when using AI-powered tools.

### Local Database Configuration

By default, the up-to-date finding models index database from the [GitHub repository](https://github.com/openimagingdata/findingmodels) is automatically downloaded to a data directory based on an online manifest. To use a pre-downloaded version (e.g., in production/Docker deployments), you can specify its path:

```bash
# Production: use pre-mounted files
DUCKDB_INDEX_PATH=/mnt/data/finding_models.duckdb
```

Alternatively, you can also lock to a specific version of the index database by specifying a download URL and its hash.

**Configuration Priority:**
1. If file exists and no URL/hash specified → uses file directly (no download)
2. If file exists with URL/hash → verifies hash, re-downloads if mismatch
3. If file doesn't exist with URL/hash → downloads from URL
4. If nothing specified → downloads from manifest.json (default)

The anatomic locations database for ontologic lookups works similarly. See `.env.sample` for more configuration options including custom download URLs and relative paths.

## CLI

The package provides CLI commands for model conversion and database management:

```shell
$ python -m findingmodel --help
```

**Available commands:**
- `fm-to-markdown` / `markdown-to-fm`: Convert between JSON and Markdown formats
- `make-info`: Generate finding descriptions and synonyms
- `make-stub-model`: Create basic finding model templates
- `config`: View current configuration
- `index`: Manage finding model index (build, update, stats)
- `anatomic`: Manage anatomic location database (build, validate, stats)

For database maintainers, see [Database Management Guide](docs/database-management.md) for detailed information on building and updating databases.

> **Note**: The AI-powered model editing functionality (`edit_model_natural_language`, `edit_model_markdown`) is available through the Python API. See an interactive demo at `scripts/edit_finding_model.py`.

## Models

### `FindingModelBase`

Basics of a finding model, including name, description, and attributes.

**Properties:**

* `name`: The name of the finding.
* `description`: A brief description of the finding. *Optional*.
* `synonyms`: Alternative names or abbreviations for the finding. *Optional*.
* `tags`: Keywords or categories associated with the finding. *Optional*.
* `attributes`: A collection of attributes objects associated with the finding.

**Methods:**

* `as_markdown()`: Generates a markdown representation of the finding model.

### `FindingModelFull`

Uses `FindingModelBase`, but adds contains more detailed metadata:

* Requiring IDs on models and attributes (with enumerated codes for values on choice attributes)
* Allows index codes on multiple levels (model, attribute, value)
* Allows contributors (people and organization)

### `FindingInfo`

Information on a finding, including description and synonyms, can add detailed description and citations.

**Properties:**

* `name`: The name of the finding.
* `synonyms`: Alternative names or abbreviations for the finding. *Optional*.
* `description`: A brief description of the finding. *Optional*.
* `detail`: A more detailed description of the finding. *Optional*.
* `citations`: A list of citations or references related to the finding. *Optional*.

## Index

The `Index` class provides fast lookup and search across finding model definitions. The index contains metadata about finding models, including their names, descriptions, synonyms, tags, and contributor information.

**Database auto-downloads on first use** - no manual setup required. For database maintenance, see the [Database Management Guide](docs/database-management.md).

### Searching and Lookup

```python
import asyncio
from findingmodel import Index

async def main():
    async with Index() as index:
        # Get count of indexed models
        count = await index.count()
        print(f"Total models indexed: {count}")

        # Lookup by ID, name, or synonym
        metadata = await index.get("abdominal aortic aneurysm")
        if metadata:
            print(f"Found: {metadata.name} ({metadata.oifm_id})")
            print(f"Description: {metadata.description}")
            print(f"Synonyms: {metadata.synonyms}")

        # Search for models (returns list of IndexEntry objects)
        results = await index.search("abdominal", limit=5)
        for result in results:
            print(f"- {result.name}: {result.oifm_id}")

        # Check if a model exists
        exists = await index.contains("pneumothorax")
        print(f"Pneumothorax exists: {exists}")

asyncio.run(main())
```

### Listing and Filtering

```python
async def browse_models():
    async with Index() as index:
        # Get all models with pagination
        models, total = await index.all(limit=20, offset=0, order_by="name", order_dir="asc")
        print(f"Showing {len(models)} of {total} total models:")
        for model in models:
            print(f"  - {model.name} ({model.oifm_id})")

        # Search by slug name pattern (exact match)
        results, count = await index.search_by_slug("pneumothorax", match_type="exact")
        print(f"\nExact matches: {count}")

        # Search by slug name pattern (prefix match - starts with)
        results, count = await index.search_by_slug("aortic", match_type="prefix", limit=10)
        print(f"\nModels starting with 'aortic': {count}")
        for result in results:
            print(f"  - {result.name}")

        # Search by slug name pattern (contains - default)
        results, count = await index.search_by_slug("abscess", limit=10)
        print(f"\nModels containing 'abscess': {count}")

        # Count models matching a pattern
        exact_count = await index.count_search("lung_nodule", match_type="exact")
        contains_count = await index.count_search("lung", match_type="contains")
        print(f"\nExact 'lung_nodule': {exact_count}")
        print(f"Contains 'lung': {contains_count}")

asyncio.run(browse_models())
```

**Available methods:**
- `all(limit, offset, order_by, order_dir)` - Get paginated list of all models with sorting
- `search_by_slug(pattern, match_type, limit, offset)` - Search by slug name with exact/prefix/contains matching
- `count_search(pattern, match_type)` - Count models matching a slug name pattern

### Working with Contributors

```python
async def get_contributors():
    async with Index() as index:
        # Get a person by GitHub username
        person = await index.get_person("talkasab")
        if person:
            print(f"Name: {person.name}, Email: {person.email}")

        # Get an organization by code
        org = await index.get_organization("MSFT")
        if org:
            print(f"Organization: {org.name}")

        # Get all people (sorted by name)
        people = await index.get_people()
        print(f"Found {len(people)} people:")
        for person in people[:5]:  # Show first 5
            print(f"  - {person.name} (@{person.github_username})")

        # Get all organizations (sorted by name)
        organizations = await index.get_organizations()
        print(f"Found {len(organizations)} organizations:")
        for org in organizations[:5]:  # Show first 5
            print(f"  - {org.name} ({org.code})")

        # Count contributors
        people_count = await index.count_people()
        org_count = await index.count_organizations()
        print(f"People: {people_count}, Organizations: {org_count}")

asyncio.run(get_contributors())
```

See [example usage in notebook](notebooks/findingmodel_index.ipynb) and the [Database Management Guide](docs/database-management.md) for information on updating the index.

## Tools

All tools are available through `findingmodel.tools`. Import them like:

```python
from findingmodel.tools import create_info_from_name, add_details_to_info
# Or import the entire tools module
import findingmodel.tools as tools
```

> **Note**: Previous function names (e.g., `describe_finding_name`, `create_finding_model_from_markdown`) are still available but deprecated. They will show deprecation warnings and point to the new names.

### `create_info_from_name()`

Takes a finding name and generates a usable description and possibly synonyms (`FindingInfo`) using OpenAI models (requires `OPENAI_API_KEY` to be set to a valid value).

```python
import asyncio
from findingmodel.tools import create_info_from_name

async def describe_finding():
    # Generate basic finding information
    info = await create_info_from_name("Pneumothorax")
    print(f"Name: {info.name}")
    print(f"Synonyms: {info.synonyms}")
    print(f"Description: {info.description[:100]}...")
    return info

info = asyncio.run(describe_finding())
# Output:
# Name: pneumothorax
# Synonyms: ['PTX']
# Description: Pneumothorax is the presence of air in the pleural space...
```

### `add_details_to_info()`

Takes a described finding as above and uses Perplexity to get a lot of possible reference information, possibly including citations (requires `PERPLEXITY_API_KEY` to be set to a valid value).

```python
import asyncio
from findingmodel.tools import add_details_to_info
from findingmodel import FindingInfo

async def enhance_finding():
    # Start with basic finding info
    finding = FindingInfo(
        name="pneumothorax", 
        synonyms=['PTX'],
        description='Pneumothorax is the presence of air in the pleural space'
    )
    
    # Add detailed information and citations
    enhanced = await add_details_to_info(finding)
    
    print(f"Detail length: {len(enhanced.detail)} characters")
    print(f"Citations found: {len(enhanced.citations)}")
    
    # Show first few citations
    for i, citation in enumerate(enhanced.citations[:3], 1):
        print(f"  {i}. {citation}")
    
    return enhanced

enhanced_info = asyncio.run(enhance_finding())
# Output:
# Detail length: 2547 characters  
# Citations found: 8
#   1. https://pubs.rsna.org/doi/full/10.1148/rg.2020200020
#   2. https://ajronline.org/doi/full/10.2214/AJR.17.18721
#   3. https://radiopaedia.org/articles/pneumothorax
```

### `create_model_from_markdown()`

Creates a `FindingModel` from a markdown file or text using OpenAI API.

```python
import asyncio
from pathlib import Path
from findingmodel.tools import create_model_from_markdown, create_info_from_name

async def create_from_markdown():
    # First create basic info about the finding
    finding_info = await create_info_from_name("pneumothorax")
    
    # Option 1: Create from markdown text
    markdown_outline = """
    # Pneumothorax Attributes
    - Size: small (<2cm), moderate (2-4cm), large (>4cm)
    - Location: apical, basilar, lateral, complete
    - Tension: present, absent, indeterminate
    - Cause: spontaneous, traumatic, iatrogenic
    """
    
    model = await create_model_from_markdown(
        finding_info, 
        markdown_text=markdown_outline
    )
    print(f"Created model with {len(model.attributes)} attributes")
    
    # Option 2: Create from markdown file
    # Save markdown to file first
    Path("pneumothorax.md").write_text(markdown_outline)
    
    model_from_file = await create_model_from_markdown(
        finding_info,
        markdown_path="pneumothorax.md"
    )
    
    # Display the attributes
    for attr in model.attributes:
        print(f"- {attr.name}: {attr.type}")
        if hasattr(attr, 'values'):
            print(f"  Values: {[v.name for v in attr.values]}")
    
    return model

model = asyncio.run(create_from_markdown())
# Output:
# Created model with 4 attributes
# - size: choice
#   Values: ['small (<2cm)', 'moderate (2-4cm)', 'large (>4cm)']
# - location: choice  
#   Values: ['apical', 'basilar', 'lateral', 'complete']
# - tension: choice
#   Values: ['present', 'absent', 'indeterminate']
# - cause: choice
#   Values: ['spontaneous', 'traumatic', 'iatrogenic']
```

### `create_model_stub_from_info()`

Given even a basic `FindingInfo`, turn it into a `FindingModelBase` object with at least two attributes:

* **presence**: Whether the finding is seen  
(present, absent, indeterminate, unknown)
* **change from prior**: How the finding has changed from prior exams  
(unchanged, stable, increased, decreased, new, resolved, no prior)

```python
import asyncio
from findingmodel.tools import create_info_from_name, create_model_stub_from_info

async def create_stub():
    # Create finding info
    finding_info = await create_info_from_name("pneumothorax")
    
    # Create a basic model stub with standard presence/change attributes
    stub_model = create_model_stub_from_info(finding_info)
    
    print(f"Model name: {stub_model.name}")
    print(f"Created model with {len(stub_model.attributes)} attributes:")
    
    for attr in stub_model.attributes:
        print(f"\n- {attr.name} ({attr.type}):")
        if hasattr(attr, 'values'):
            for value in attr.values:
                print(f"  • {value.name}")
    
    # You can also add tags
    stub_with_tags = create_model_stub_from_info(
        finding_info, 
        tags=["chest", "emergency", "trauma"]
    )
    print(f"\nTags: {stub_with_tags.tags}")
    
    return stub_model

stub = asyncio.run(create_stub())
# Output:
# Model name: pneumothorax
# Created model with 2 attributes:
# 
# - presence (choice):
#   • present
#   • absent  
#   • indeterminate
#   • unknown
# 
# - change from prior (choice):
#   • unchanged
#   • stable
#   • increased
#   • decreased
#   • new
#   • resolved
#   • no prior
# 
# Tags: ['chest', 'emergency', 'trauma']
```

### `add_ids_to_model()`

Generates and adds OIFM IDs to a `FindingModelBase` object and returns it as a `FindingModelFull` object. Note that the `source` parameter refers to the source component of the OIFM ID, which describes the originating organization of the model (e.g., `MGB` for Mass General Brigham and `MSFT` for Microsoft).

```python
import asyncio
from findingmodel.tools import (
    add_ids_to_model, 
    create_model_stub_from_info,
    create_info_from_name
)

async def add_identifiers():
    # Create a basic model (without IDs)
    finding_info = await create_info_from_name("pneumothorax")
    stub_model = create_model_stub_from_info(finding_info)
    
    # Add OIFM IDs for tracking and standardization
    # Source can be 3 or 4 letters (e.g., "MGB", "MSFT")
    full_model = add_ids_to_model(stub_model, source="MSFT")
    
    print(f"Model ID: {full_model.oifm_id}")
    print(f"Attribute IDs:")
    for attr in full_model.attributes:
        print(f"  - {attr.name}: {attr.oifma_id}")
        if hasattr(attr, 'values'):
            for value in attr.values:
                print(f"    • {value.name}: {value.oifmv_id}")
    
    return full_model

full_model = asyncio.run(add_identifiers())
# Output:
# Model ID: OIFM_MSFT_123456
# Attribute IDs:
#   - presence: OIFMA_MSFT_789012
#     • present: OIFMV_MSFT_345678
#     • absent: OIFMV_MSFT_901234
#     • indeterminate: OIFMV_MSFT_567890
#     • unknown: OIFMV_MSFT_123456
#   - change from prior: OIFMA_MSFT_789013
#     • unchanged: OIFMV_MSFT_345679
#     • stable: OIFMV_MSFT_901235
#     ...

### `assign_real_attribute_ids()`

Finalizes placeholder attribute IDs (`PLACEHOLDER_ATTRIBUTE_ID`) that were created through the editing workflows. This is used by the interactive demos before saving, but you can also call it directly when scripting bulk edits.

```python
from findingmodel.finding_model import FindingModelFull
from findingmodel.tools.add_ids import PLACEHOLDER_ATTRIBUTE_ID
from findingmodel.tools.model_editor import assign_real_attribute_ids


def finalize_ids(model_json: str) -> FindingModelFull:
    model = FindingModelFull.model_validate_json(model_json)
    # Ensure any newly added attributes receive permanent IDs and value codes
    finalized = assign_real_attribute_ids(model)
    return finalized


# Placeholder-rich model JSON from an editing session
with open("pulmonary_embolism.edited.json", "r") as fh:
    edited_json = fh.read()

model_with_ids = finalize_ids(edited_json)
assert all(attr.oifma_id != PLACEHOLDER_ATTRIBUTE_ID for attr in model_with_ids.attributes)
```
```

### `add_standard_codes_to_model()`

Edits a `FindingModelFull` in place to include some RadLex and SNOMED-CT codes that correspond to some typical situations.

```python
import asyncio
from findingmodel.tools import (
    add_standard_codes_to_model,
    add_ids_to_model,
    create_model_stub_from_info,
    create_info_from_name
)

async def add_medical_codes():
    # Create a full model with IDs
    finding_info = await create_info_from_name("pneumothorax")
    stub_model = create_model_stub_from_info(finding_info)
    full_model = add_ids_to_model(stub_model, source="MSFT")
    
    # Add standard medical vocabulary codes
    add_standard_codes_to_model(full_model)
    
    print("Added standard codes:")
    
    # Check model-level codes
    if full_model.index_codes:
        print(f"\nModel codes:")
        for code in full_model.index_codes:
            print(f"  - {code.system}: {code.code} ({code.display})")
    
    # Check attribute-level codes
    for attr in full_model.attributes:
        if attr.index_codes:
            print(f"\n{attr.name} attribute codes:")
            for code in attr.index_codes:
                print(f"  - {code.system}: {code.code}")
        
        # Check value-level codes
        if hasattr(attr, 'values'):
            for value in attr.values:
                if value.index_codes:
                    print(f"  {value.name} value codes:")
                    for code in value.index_codes:
                        print(f"    - {code.system}: {code.code}")
    
    return full_model

model_with_codes = asyncio.run(add_medical_codes())
# Output:
# Added standard codes:
# 
# Model codes:
#   - RadLex: RID5352 (pneumothorax)
#   - SNOMED-CT: 36118008 (Pneumothorax)
# 
# presence attribute codes:
#   - RadLex: RID39039
#   present value codes:
#     - RadLex: RID28472
#   absent value codes:
#     - RadLex: RID28473
# ...
```

### `find_similar_models()`

Searches for existing finding models in the database that are similar to a proposed new finding. This helps avoid creating duplicate models by identifying existing models that could be edited instead. Uses AI agents to perform intelligent search and analysis.

```python
import asyncio
from findingmodel.tools import find_similar_models
from findingmodel.index import Index

async def check_for_similar_models():
    # Initialize index (DuckDB backend)
    index = Index()
    
    # Search for models similar to a proposed finding
    analysis = await find_similar_models(
        finding_name="pneumothorax",
        description="Presence of air in the pleural space causing lung collapse",
        synonyms=["PTX", "collapsed lung"],
        index=index  # Optional, will create one if not provided
    )
    
    print(f"Recommendation: {analysis.recommendation}")
    print(f"Confidence: {analysis.confidence:.2f}")
    
    if analysis.similar_models:
        print("
Similar existing models found:")
        for model in analysis.similar_models:
            print(f"  - {model.name} (ID: {model.oifm_id})")
    
    # The recommendation will be one of:
    # - "edit_existing": Very similar model found, edit it instead
    # - "create_new": No similar models, safe to create new one
    # - "review_needed": Some similarity found, manual review recommended
    
    return analysis

result = asyncio.run(check_for_similar_models())
# Output:
# Recommendation: edit_existing
# Confidence: 0.90
# 
# Similar existing models found:
#   - pneumothorax (ID: OIFM_MSFT_123456)
```

**Key Features:**
- **Intelligent search**: Uses AI agents to search with various terms and strategies
- **Duplicate prevention**: Identifies if a model already exists for the finding
- **Smart recommendations**: Provides guidance on whether to create new or edit existing
- **Synonym matching**: Checks both names and synonyms for matches
- **Confidence scoring**: Indicates how confident the system is in its recommendation

### `find_anatomic_locations()`

Finds relevant anatomic locations for a finding using a two-agent workflow. The search agent generates diverse queries to search medical ontology databases (anatomic_locations, radlex, snomedct), while the matching agent selects the best primary and alternate locations based on clinical relevance and specificity.

```python
import asyncio
from findingmodel.tools import find_anatomic_locations

async def find_locations():
    # Find anatomic locations for a finding
    result = await find_anatomic_locations(
        finding_name="PCL tear",
        description="Tear of the posterior cruciate ligament",
        search_model="gpt-4o-mini",  # Optional, defaults to small model
        matching_model="gpt-4o"      # Optional, defaults to main model
    )
    
    print(f"Primary location: {result.primary_location.concept_text}")
    print(f"  ID: {result.primary_location.concept_id}")
    print(f"  Table: {result.primary_location.table_name}")
    
    if result.alternate_locations:
        print("\nAlternate locations:")
        for alt in result.alternate_locations:
            print(f"  - {alt.concept_text} ({alt.table_name})")
    
    print(f"\nReasoning: {result.reasoning}")
    
    # Convert to IndexCode for use in finding models
    index_code = result.primary_location.as_index_code()
    print(f"\nAs IndexCode: {index_code.system}:{index_code.code}")
    
    return result

result = asyncio.run(find_locations())
# Output:
# Primary location: Posterior cruciate ligament
#   ID: RID2905
#   Table: radlex
# 
# Alternate locations:
#   - Knee joint (anatomic_locations)
#   - Cruciate ligament structure (snomedct)
# 
# Reasoning: Selected "Posterior cruciate ligament" as the primary location because...
# 
# As IndexCode: RADLEX:RID2905
```

**Key Features:**
- **Two-agent architecture**: Search agent finds candidates, matching agent selects best options
- **Multiple ontology sources**: Searches across anatomic_locations, RadLex, and SNOMED-CT
- **Intelligent selection**: Finds the "sweet spot" of specificity - specific enough to be accurate but general enough to be useful
- **Reusable components**: `LanceDBOntologySearchClient` can be used for other ontology searches
- **Production ready**: Proper error handling, logging, and connection lifecycle management

### `match_ontology_concepts()`

High-performance search for relevant medical concepts across multiple ontology databases. Supports both LanceDB vector search and BioOntology REST API through a flexible Protocol-based architecture.

```python
import asyncio
from findingmodel.tools.ontology_concept_match import match_ontology_concepts

async def search_concepts():
    # Automatically uses all configured backends (LanceDB and/or BioOntology)
    result = await match_ontology_concepts(
        finding_name="pneumonia",
        finding_description="Inflammation of lung parenchyma",  # Optional
        exclude_anatomical=True  # Exclude anatomical structures (default: True)
    )
    
    print(f"Exact matches ({len(result.exact_matches)}):")
    for concept in result.exact_matches:
        print(f"  - {concept.code}: {concept.text}")
    
    print(f"\nShould include ({len(result.should_include)}):")
    for concept in result.should_include:
        print(f"  - {concept.code}: {concept.text}")
    
    print(f"\nMarginal relevance ({len(result.marginal)}):")
    for concept in result.marginal:
        print(f"  - {concept.code}: {concept.text}")
    
    return result

result = asyncio.run(search_concepts())
# Output:
# Exact matches (5):
#   - RID5350: pneumonia
#   - 233604007: Pneumonia
#   - RID34769: viral pneumonia
#   - 53084003: Bacterial pneumonia
#   - RID3541: pneumonitis
# 
# Should include (3):
#   - RID5351: lobar pneumonia
#   - RID5352: bronchopneumonia
#   - 233607000: Atypical pneumonia
# 
# Marginal relevance (2):
#   - RID4866: pulmonary edema
#   - RID34637: bronchitis
```

**Key Features:**
- **Multi-backend support**: Automatically uses LanceDB and/or BioOntology based on configuration
- **Protocol-based architecture**: Clean abstraction allows easy addition of new search providers
- **High performance**: ~10 second searches with parallel backend execution
- **Guaranteed exact matches**: Post-processing ensures exact name matches are never missed
- **Smart categorization**: Three tiers - exact matches, should include, marginal
- **Excludes anatomy**: Focuses on diseases/conditions (use `find_anatomic_locations()` for anatomy)

### `edit_model_natural_language()` and `edit_model_markdown()`

AI-powered editing tools for finding models with two modes: natural language commands and Markdown-based editing. Both preserve existing OIFM IDs and only allow safe additions and non-semantic text changes.

```python
import asyncio
from findingmodel import FindingModelFull
from findingmodel.tools.model_editor import (
    edit_model_natural_language,
    edit_model_markdown,
    export_model_for_editing
)

async def edit_with_natural_language():
    # Load an existing model
    with open("pneumothorax.fm.json") as f:
        model = FindingModelFull.model_validate_json(f.read())
    
    # Add a new attribute using natural language
    result = await edit_model_natural_language(
        model=model,
        command="Add severity attribute with values mild, moderate, severe"
    )
    
    # Check for any rejected changes
    if result.rejections:
        print("Some changes were rejected:")
        for rejection in result.rejections:
            print(f"  - {rejection}")
    
    # The updated model with new attribute
    updated_model = result.model
    print(f"Model now has {len(updated_model.attributes)} attributes")
    
    return result

async def edit_with_markdown():
    # Load an existing model
    with open("pneumothorax.fm.json") as f:
        model = FindingModelFull.model_validate_json(f.read())
    
    # Export to editable Markdown format
    markdown_content = export_model_for_editing(model)
    print("Current Markdown:")
    print(markdown_content)
    
    # Add new attribute section to the markdown
    edited_markdown = markdown_content + """
### severity

Severity of the pneumothorax

- mild: Small pneumothorax with minimal clinical impact
- moderate: Medium-sized pneumothorax requiring monitoring
- severe: Large pneumothorax requiring immediate intervention

"""
    
    # Apply the Markdown edits
    result = await edit_model_markdown(
        model=model,
        edited_markdown=edited_markdown
    )
    
    # Check results
    if result.rejections:
        print("Some changes were rejected:")
        for rejection in result.rejections:
            print(f"  - {rejection}")
    
    updated_model = result.model
    print(f"Model now has {len(updated_model.attributes)} attributes")
    
    return result

# Run examples
nl_result = asyncio.run(edit_with_natural_language())
md_result = asyncio.run(edit_with_markdown())
```

**Safety Features:**
- **ID preservation**: All existing OIFM IDs (model, attribute, value) are preserved
- **Safe changes only**: Only allows adding new attributes/values or editing non-semantic text
- **Rejection feedback**: Clear explanations when changes are rejected as unsafe
- **Validation**: Built-in validation ensures model integrity and proper ID generation

**Editable Markdown Format:**
```markdown
# Model Name

Model description here.

Synonyms: synonym1, synonym2

## Attributes

### attribute_name

Optional attribute description

- value1: Optional value description
- value2: Another value
- value3

### another_attribute

- option1
- option2
```

**Use Cases:**
- **Natural Language**: "Add location attribute with upper, middle, lower lobe options"
- **Markdown**: Direct editing of exported model structure with full control over formatting
- **Collaborative**: Export to Markdown, share with clinical experts, import their edits
- **Batch editing**: Multiple attribute additions in a single Markdown edit session

            

Raw data

            {
    "_id": null,
    "home_page": null,
    "name": "findingmodel",
    "maintainer": null,
    "docs_url": null,
    "requires_python": ">=3.11",
    "maintainer_email": null,
    "keywords": "finding model, common data element, medical imaging, data model, radiology",
    "author": "Tarik Alkasab, Vijay Dawal",
    "author_email": "Tarik Alkasab <tarik@alkasab.org>, Vijay Dawal <vijaydawal@microsoft.com>",
    "download_url": "https://files.pythonhosted.org/packages/b0/dd/4eb8bfedffc1c8ccb9e8442d2ecb082a1c76dc4f2b7d8db12d5e2f70ae51/findingmodel-0.5.0.tar.gz",
    "platform": null,
    "description": "# `findingmodel` Package\n\nA Python library for managing Open Imaging Finding Models - structured data models used to describe medical imaging findings in radiology reports. The library provides tools for creating, converting, and managing these finding models with AI-powered features for medical ontology integration.\n\n## Features\n\n- **Finding Model Management**: Create and manage structured medical finding models with attributes\n- **AI-Powered Tools**: Generate finding descriptions, synonyms, and detailed information using OpenAI and Perplexity\n- **Medical Ontology Integration**: Search and match concepts across multiple backends:\n  - **BioOntology API**: Access to 800+ medical ontologies including SNOMED-CT, ICD-10, LOINC\n  - **DuckDB Search**: High-performance vector and full-text search with HNSW indexing\n- **Protocol-Based Architecture**: Flexible backend support with automatic parallel execution\n- **Finding Model Index**: Fast lookup and search across finding model definitions\n- **Anatomic Location Discovery**: Two-agent AI system for finding relevant anatomic locations\n\n## Installation\n\n```bash\npip install findingmodel\n```\n\n## Configuration\n\nConfigure the library by creating a `.env` file in your project root. See `.env.sample` for all available options.\n\n### API Keys (Required/Optional by Feature)\n\nDifferent features require different API keys:\n\n| Feature | Required Key | Purpose |\n|---------|--------------|---------|\n| **Core AI Features** | `OPENAI_API_KEY` | Generate descriptions, synonyms, create models from markdown |\n| **Detailed Finding Info** | `PERPLEXITY_API_KEY` | Add citations and detailed descriptions (requires OpenAI key too) |\n| **800+ Medical Ontologies** | `BIOONTOLOGY_API_KEY` | Access BioOntology.org for SNOMED-CT, ICD-10, LOINC, etc. |\n\n```bash\n# Required for most features\nOPENAI_API_KEY=your_key_here\n\n# Optional - only needed for add_details_to_info()\nPERPLEXITY_API_KEY=your_key_here\n\n# Optional - only needed for BioOntology backend in ontology searches\nBIOONTOLOGY_API_KEY=your_key_here\n```\n\n**Note:** The Index and anatomic location search work without any API keys (DuckDB backend). OpenAI is only needed when using AI-powered tools.\n\n### Local Database Configuration\n\nBy default, the up-to-date finding models index database from the [GitHub repository](https://github.com/openimagingdata/findingmodels) is automatically downloaded to a data directory based on an online manifest. To use a pre-downloaded version (e.g., in production/Docker deployments), you can specify its path:\n\n```bash\n# Production: use pre-mounted files\nDUCKDB_INDEX_PATH=/mnt/data/finding_models.duckdb\n```\n\nAlternatively, you can also lock to a specific version of the index database by specifying a download URL and its hash.\n\n**Configuration Priority:**\n1. If file exists and no URL/hash specified \u2192 uses file directly (no download)\n2. If file exists with URL/hash \u2192 verifies hash, re-downloads if mismatch\n3. If file doesn't exist with URL/hash \u2192 downloads from URL\n4. If nothing specified \u2192 downloads from manifest.json (default)\n\nThe anatomic locations database for ontologic lookups works similarly. See `.env.sample` for more configuration options including custom download URLs and relative paths.\n\n## CLI\n\nThe package provides CLI commands for model conversion and database management:\n\n```shell\n$ python -m findingmodel --help\n```\n\n**Available commands:**\n- `fm-to-markdown` / `markdown-to-fm`: Convert between JSON and Markdown formats\n- `make-info`: Generate finding descriptions and synonyms\n- `make-stub-model`: Create basic finding model templates\n- `config`: View current configuration\n- `index`: Manage finding model index (build, update, stats)\n- `anatomic`: Manage anatomic location database (build, validate, stats)\n\nFor database maintainers, see [Database Management Guide](docs/database-management.md) for detailed information on building and updating databases.\n\n> **Note**: The AI-powered model editing functionality (`edit_model_natural_language`, `edit_model_markdown`) is available through the Python API. See an interactive demo at `scripts/edit_finding_model.py`.\n\n## Models\n\n### `FindingModelBase`\n\nBasics of a finding model, including name, description, and attributes.\n\n**Properties:**\n\n* `name`: The name of the finding.\n* `description`: A brief description of the finding. *Optional*.\n* `synonyms`: Alternative names or abbreviations for the finding. *Optional*.\n* `tags`: Keywords or categories associated with the finding. *Optional*.\n* `attributes`: A collection of attributes objects associated with the finding.\n\n**Methods:**\n\n* `as_markdown()`: Generates a markdown representation of the finding model.\n\n### `FindingModelFull`\n\nUses `FindingModelBase`, but adds contains more detailed metadata:\n\n* Requiring IDs on models and attributes (with enumerated codes for values on choice attributes)\n* Allows index codes on multiple levels (model, attribute, value)\n* Allows contributors (people and organization)\n\n### `FindingInfo`\n\nInformation on a finding, including description and synonyms, can add detailed description and citations.\n\n**Properties:**\n\n* `name`: The name of the finding.\n* `synonyms`: Alternative names or abbreviations for the finding. *Optional*.\n* `description`: A brief description of the finding. *Optional*.\n* `detail`: A more detailed description of the finding. *Optional*.\n* `citations`: A list of citations or references related to the finding. *Optional*.\n\n## Index\n\nThe `Index` class provides fast lookup and search across finding model definitions. The index contains metadata about finding models, including their names, descriptions, synonyms, tags, and contributor information.\n\n**Database auto-downloads on first use** - no manual setup required. For database maintenance, see the [Database Management Guide](docs/database-management.md).\n\n### Searching and Lookup\n\n```python\nimport asyncio\nfrom findingmodel import Index\n\nasync def main():\n    async with Index() as index:\n        # Get count of indexed models\n        count = await index.count()\n        print(f\"Total models indexed: {count}\")\n\n        # Lookup by ID, name, or synonym\n        metadata = await index.get(\"abdominal aortic aneurysm\")\n        if metadata:\n            print(f\"Found: {metadata.name} ({metadata.oifm_id})\")\n            print(f\"Description: {metadata.description}\")\n            print(f\"Synonyms: {metadata.synonyms}\")\n\n        # Search for models (returns list of IndexEntry objects)\n        results = await index.search(\"abdominal\", limit=5)\n        for result in results:\n            print(f\"- {result.name}: {result.oifm_id}\")\n\n        # Check if a model exists\n        exists = await index.contains(\"pneumothorax\")\n        print(f\"Pneumothorax exists: {exists}\")\n\nasyncio.run(main())\n```\n\n### Listing and Filtering\n\n```python\nasync def browse_models():\n    async with Index() as index:\n        # Get all models with pagination\n        models, total = await index.all(limit=20, offset=0, order_by=\"name\", order_dir=\"asc\")\n        print(f\"Showing {len(models)} of {total} total models:\")\n        for model in models:\n            print(f\"  - {model.name} ({model.oifm_id})\")\n\n        # Search by slug name pattern (exact match)\n        results, count = await index.search_by_slug(\"pneumothorax\", match_type=\"exact\")\n        print(f\"\\nExact matches: {count}\")\n\n        # Search by slug name pattern (prefix match - starts with)\n        results, count = await index.search_by_slug(\"aortic\", match_type=\"prefix\", limit=10)\n        print(f\"\\nModels starting with 'aortic': {count}\")\n        for result in results:\n            print(f\"  - {result.name}\")\n\n        # Search by slug name pattern (contains - default)\n        results, count = await index.search_by_slug(\"abscess\", limit=10)\n        print(f\"\\nModels containing 'abscess': {count}\")\n\n        # Count models matching a pattern\n        exact_count = await index.count_search(\"lung_nodule\", match_type=\"exact\")\n        contains_count = await index.count_search(\"lung\", match_type=\"contains\")\n        print(f\"\\nExact 'lung_nodule': {exact_count}\")\n        print(f\"Contains 'lung': {contains_count}\")\n\nasyncio.run(browse_models())\n```\n\n**Available methods:**\n- `all(limit, offset, order_by, order_dir)` - Get paginated list of all models with sorting\n- `search_by_slug(pattern, match_type, limit, offset)` - Search by slug name with exact/prefix/contains matching\n- `count_search(pattern, match_type)` - Count models matching a slug name pattern\n\n### Working with Contributors\n\n```python\nasync def get_contributors():\n    async with Index() as index:\n        # Get a person by GitHub username\n        person = await index.get_person(\"talkasab\")\n        if person:\n            print(f\"Name: {person.name}, Email: {person.email}\")\n\n        # Get an organization by code\n        org = await index.get_organization(\"MSFT\")\n        if org:\n            print(f\"Organization: {org.name}\")\n\n        # Get all people (sorted by name)\n        people = await index.get_people()\n        print(f\"Found {len(people)} people:\")\n        for person in people[:5]:  # Show first 5\n            print(f\"  - {person.name} (@{person.github_username})\")\n\n        # Get all organizations (sorted by name)\n        organizations = await index.get_organizations()\n        print(f\"Found {len(organizations)} organizations:\")\n        for org in organizations[:5]:  # Show first 5\n            print(f\"  - {org.name} ({org.code})\")\n\n        # Count contributors\n        people_count = await index.count_people()\n        org_count = await index.count_organizations()\n        print(f\"People: {people_count}, Organizations: {org_count}\")\n\nasyncio.run(get_contributors())\n```\n\nSee [example usage in notebook](notebooks/findingmodel_index.ipynb) and the [Database Management Guide](docs/database-management.md) for information on updating the index.\n\n## Tools\n\nAll tools are available through `findingmodel.tools`. Import them like:\n\n```python\nfrom findingmodel.tools import create_info_from_name, add_details_to_info\n# Or import the entire tools module\nimport findingmodel.tools as tools\n```\n\n> **Note**: Previous function names (e.g., `describe_finding_name`, `create_finding_model_from_markdown`) are still available but deprecated. They will show deprecation warnings and point to the new names.\n\n### `create_info_from_name()`\n\nTakes a finding name and generates a usable description and possibly synonyms (`FindingInfo`) using OpenAI models (requires `OPENAI_API_KEY` to be set to a valid value).\n\n```python\nimport asyncio\nfrom findingmodel.tools import create_info_from_name\n\nasync def describe_finding():\n    # Generate basic finding information\n    info = await create_info_from_name(\"Pneumothorax\")\n    print(f\"Name: {info.name}\")\n    print(f\"Synonyms: {info.synonyms}\")\n    print(f\"Description: {info.description[:100]}...\")\n    return info\n\ninfo = asyncio.run(describe_finding())\n# Output:\n# Name: pneumothorax\n# Synonyms: ['PTX']\n# Description: Pneumothorax is the presence of air in the pleural space...\n```\n\n### `add_details_to_info()`\n\nTakes a described finding as above and uses Perplexity to get a lot of possible reference information, possibly including citations (requires `PERPLEXITY_API_KEY` to be set to a valid value).\n\n```python\nimport asyncio\nfrom findingmodel.tools import add_details_to_info\nfrom findingmodel import FindingInfo\n\nasync def enhance_finding():\n    # Start with basic finding info\n    finding = FindingInfo(\n        name=\"pneumothorax\", \n        synonyms=['PTX'],\n        description='Pneumothorax is the presence of air in the pleural space'\n    )\n    \n    # Add detailed information and citations\n    enhanced = await add_details_to_info(finding)\n    \n    print(f\"Detail length: {len(enhanced.detail)} characters\")\n    print(f\"Citations found: {len(enhanced.citations)}\")\n    \n    # Show first few citations\n    for i, citation in enumerate(enhanced.citations[:3], 1):\n        print(f\"  {i}. {citation}\")\n    \n    return enhanced\n\nenhanced_info = asyncio.run(enhance_finding())\n# Output:\n# Detail length: 2547 characters  \n# Citations found: 8\n#   1. https://pubs.rsna.org/doi/full/10.1148/rg.2020200020\n#   2. https://ajronline.org/doi/full/10.2214/AJR.17.18721\n#   3. https://radiopaedia.org/articles/pneumothorax\n```\n\n### `create_model_from_markdown()`\n\nCreates a `FindingModel` from a markdown file or text using OpenAI API.\n\n```python\nimport asyncio\nfrom pathlib import Path\nfrom findingmodel.tools import create_model_from_markdown, create_info_from_name\n\nasync def create_from_markdown():\n    # First create basic info about the finding\n    finding_info = await create_info_from_name(\"pneumothorax\")\n    \n    # Option 1: Create from markdown text\n    markdown_outline = \"\"\"\n    # Pneumothorax Attributes\n    - Size: small (<2cm), moderate (2-4cm), large (>4cm)\n    - Location: apical, basilar, lateral, complete\n    - Tension: present, absent, indeterminate\n    - Cause: spontaneous, traumatic, iatrogenic\n    \"\"\"\n    \n    model = await create_model_from_markdown(\n        finding_info, \n        markdown_text=markdown_outline\n    )\n    print(f\"Created model with {len(model.attributes)} attributes\")\n    \n    # Option 2: Create from markdown file\n    # Save markdown to file first\n    Path(\"pneumothorax.md\").write_text(markdown_outline)\n    \n    model_from_file = await create_model_from_markdown(\n        finding_info,\n        markdown_path=\"pneumothorax.md\"\n    )\n    \n    # Display the attributes\n    for attr in model.attributes:\n        print(f\"- {attr.name}: {attr.type}\")\n        if hasattr(attr, 'values'):\n            print(f\"  Values: {[v.name for v in attr.values]}\")\n    \n    return model\n\nmodel = asyncio.run(create_from_markdown())\n# Output:\n# Created model with 4 attributes\n# - size: choice\n#   Values: ['small (<2cm)', 'moderate (2-4cm)', 'large (>4cm)']\n# - location: choice  \n#   Values: ['apical', 'basilar', 'lateral', 'complete']\n# - tension: choice\n#   Values: ['present', 'absent', 'indeterminate']\n# - cause: choice\n#   Values: ['spontaneous', 'traumatic', 'iatrogenic']\n```\n\n### `create_model_stub_from_info()`\n\nGiven even a basic `FindingInfo`, turn it into a `FindingModelBase` object with at least two attributes:\n\n* **presence**: Whether the finding is seen  \n(present, absent, indeterminate, unknown)\n* **change from prior**: How the finding has changed from prior exams  \n(unchanged, stable, increased, decreased, new, resolved, no prior)\n\n```python\nimport asyncio\nfrom findingmodel.tools import create_info_from_name, create_model_stub_from_info\n\nasync def create_stub():\n    # Create finding info\n    finding_info = await create_info_from_name(\"pneumothorax\")\n    \n    # Create a basic model stub with standard presence/change attributes\n    stub_model = create_model_stub_from_info(finding_info)\n    \n    print(f\"Model name: {stub_model.name}\")\n    print(f\"Created model with {len(stub_model.attributes)} attributes:\")\n    \n    for attr in stub_model.attributes:\n        print(f\"\\n- {attr.name} ({attr.type}):\")\n        if hasattr(attr, 'values'):\n            for value in attr.values:\n                print(f\"  \u2022 {value.name}\")\n    \n    # You can also add tags\n    stub_with_tags = create_model_stub_from_info(\n        finding_info, \n        tags=[\"chest\", \"emergency\", \"trauma\"]\n    )\n    print(f\"\\nTags: {stub_with_tags.tags}\")\n    \n    return stub_model\n\nstub = asyncio.run(create_stub())\n# Output:\n# Model name: pneumothorax\n# Created model with 2 attributes:\n# \n# - presence (choice):\n#   \u2022 present\n#   \u2022 absent  \n#   \u2022 indeterminate\n#   \u2022 unknown\n# \n# - change from prior (choice):\n#   \u2022 unchanged\n#   \u2022 stable\n#   \u2022 increased\n#   \u2022 decreased\n#   \u2022 new\n#   \u2022 resolved\n#   \u2022 no prior\n# \n# Tags: ['chest', 'emergency', 'trauma']\n```\n\n### `add_ids_to_model()`\n\nGenerates and adds OIFM IDs to a `FindingModelBase` object and returns it as a `FindingModelFull` object. Note that the `source` parameter refers to the source component of the OIFM ID, which describes the originating organization of the model (e.g., `MGB` for Mass General Brigham and `MSFT` for Microsoft).\n\n```python\nimport asyncio\nfrom findingmodel.tools import (\n    add_ids_to_model, \n    create_model_stub_from_info,\n    create_info_from_name\n)\n\nasync def add_identifiers():\n    # Create a basic model (without IDs)\n    finding_info = await create_info_from_name(\"pneumothorax\")\n    stub_model = create_model_stub_from_info(finding_info)\n    \n    # Add OIFM IDs for tracking and standardization\n    # Source can be 3 or 4 letters (e.g., \"MGB\", \"MSFT\")\n    full_model = add_ids_to_model(stub_model, source=\"MSFT\")\n    \n    print(f\"Model ID: {full_model.oifm_id}\")\n    print(f\"Attribute IDs:\")\n    for attr in full_model.attributes:\n        print(f\"  - {attr.name}: {attr.oifma_id}\")\n        if hasattr(attr, 'values'):\n            for value in attr.values:\n                print(f\"    \u2022 {value.name}: {value.oifmv_id}\")\n    \n    return full_model\n\nfull_model = asyncio.run(add_identifiers())\n# Output:\n# Model ID: OIFM_MSFT_123456\n# Attribute IDs:\n#   - presence: OIFMA_MSFT_789012\n#     \u2022 present: OIFMV_MSFT_345678\n#     \u2022 absent: OIFMV_MSFT_901234\n#     \u2022 indeterminate: OIFMV_MSFT_567890\n#     \u2022 unknown: OIFMV_MSFT_123456\n#   - change from prior: OIFMA_MSFT_789013\n#     \u2022 unchanged: OIFMV_MSFT_345679\n#     \u2022 stable: OIFMV_MSFT_901235\n#     ...\n\n### `assign_real_attribute_ids()`\n\nFinalizes placeholder attribute IDs (`PLACEHOLDER_ATTRIBUTE_ID`) that were created through the editing workflows. This is used by the interactive demos before saving, but you can also call it directly when scripting bulk edits.\n\n```python\nfrom findingmodel.finding_model import FindingModelFull\nfrom findingmodel.tools.add_ids import PLACEHOLDER_ATTRIBUTE_ID\nfrom findingmodel.tools.model_editor import assign_real_attribute_ids\n\n\ndef finalize_ids(model_json: str) -> FindingModelFull:\n    model = FindingModelFull.model_validate_json(model_json)\n    # Ensure any newly added attributes receive permanent IDs and value codes\n    finalized = assign_real_attribute_ids(model)\n    return finalized\n\n\n# Placeholder-rich model JSON from an editing session\nwith open(\"pulmonary_embolism.edited.json\", \"r\") as fh:\n    edited_json = fh.read()\n\nmodel_with_ids = finalize_ids(edited_json)\nassert all(attr.oifma_id != PLACEHOLDER_ATTRIBUTE_ID for attr in model_with_ids.attributes)\n```\n```\n\n### `add_standard_codes_to_model()`\n\nEdits a `FindingModelFull` in place to include some RadLex and SNOMED-CT codes that correspond to some typical situations.\n\n```python\nimport asyncio\nfrom findingmodel.tools import (\n    add_standard_codes_to_model,\n    add_ids_to_model,\n    create_model_stub_from_info,\n    create_info_from_name\n)\n\nasync def add_medical_codes():\n    # Create a full model with IDs\n    finding_info = await create_info_from_name(\"pneumothorax\")\n    stub_model = create_model_stub_from_info(finding_info)\n    full_model = add_ids_to_model(stub_model, source=\"MSFT\")\n    \n    # Add standard medical vocabulary codes\n    add_standard_codes_to_model(full_model)\n    \n    print(\"Added standard codes:\")\n    \n    # Check model-level codes\n    if full_model.index_codes:\n        print(f\"\\nModel codes:\")\n        for code in full_model.index_codes:\n            print(f\"  - {code.system}: {code.code} ({code.display})\")\n    \n    # Check attribute-level codes\n    for attr in full_model.attributes:\n        if attr.index_codes:\n            print(f\"\\n{attr.name} attribute codes:\")\n            for code in attr.index_codes:\n                print(f\"  - {code.system}: {code.code}\")\n        \n        # Check value-level codes\n        if hasattr(attr, 'values'):\n            for value in attr.values:\n                if value.index_codes:\n                    print(f\"  {value.name} value codes:\")\n                    for code in value.index_codes:\n                        print(f\"    - {code.system}: {code.code}\")\n    \n    return full_model\n\nmodel_with_codes = asyncio.run(add_medical_codes())\n# Output:\n# Added standard codes:\n# \n# Model codes:\n#   - RadLex: RID5352 (pneumothorax)\n#   - SNOMED-CT: 36118008 (Pneumothorax)\n# \n# presence attribute codes:\n#   - RadLex: RID39039\n#   present value codes:\n#     - RadLex: RID28472\n#   absent value codes:\n#     - RadLex: RID28473\n# ...\n```\n\n### `find_similar_models()`\n\nSearches for existing finding models in the database that are similar to a proposed new finding. This helps avoid creating duplicate models by identifying existing models that could be edited instead. Uses AI agents to perform intelligent search and analysis.\n\n```python\nimport asyncio\nfrom findingmodel.tools import find_similar_models\nfrom findingmodel.index import Index\n\nasync def check_for_similar_models():\n    # Initialize index (DuckDB backend)\n    index = Index()\n    \n    # Search for models similar to a proposed finding\n    analysis = await find_similar_models(\n        finding_name=\"pneumothorax\",\n        description=\"Presence of air in the pleural space causing lung collapse\",\n        synonyms=[\"PTX\", \"collapsed lung\"],\n        index=index  # Optional, will create one if not provided\n    )\n    \n    print(f\"Recommendation: {analysis.recommendation}\")\n    print(f\"Confidence: {analysis.confidence:.2f}\")\n    \n    if analysis.similar_models:\n        print(\"\nSimilar existing models found:\")\n        for model in analysis.similar_models:\n            print(f\"  - {model.name} (ID: {model.oifm_id})\")\n    \n    # The recommendation will be one of:\n    # - \"edit_existing\": Very similar model found, edit it instead\n    # - \"create_new\": No similar models, safe to create new one\n    # - \"review_needed\": Some similarity found, manual review recommended\n    \n    return analysis\n\nresult = asyncio.run(check_for_similar_models())\n# Output:\n# Recommendation: edit_existing\n# Confidence: 0.90\n# \n# Similar existing models found:\n#   - pneumothorax (ID: OIFM_MSFT_123456)\n```\n\n**Key Features:**\n- **Intelligent search**: Uses AI agents to search with various terms and strategies\n- **Duplicate prevention**: Identifies if a model already exists for the finding\n- **Smart recommendations**: Provides guidance on whether to create new or edit existing\n- **Synonym matching**: Checks both names and synonyms for matches\n- **Confidence scoring**: Indicates how confident the system is in its recommendation\n\n### `find_anatomic_locations()`\n\nFinds relevant anatomic locations for a finding using a two-agent workflow. The search agent generates diverse queries to search medical ontology databases (anatomic_locations, radlex, snomedct), while the matching agent selects the best primary and alternate locations based on clinical relevance and specificity.\n\n```python\nimport asyncio\nfrom findingmodel.tools import find_anatomic_locations\n\nasync def find_locations():\n    # Find anatomic locations for a finding\n    result = await find_anatomic_locations(\n        finding_name=\"PCL tear\",\n        description=\"Tear of the posterior cruciate ligament\",\n        search_model=\"gpt-4o-mini\",  # Optional, defaults to small model\n        matching_model=\"gpt-4o\"      # Optional, defaults to main model\n    )\n    \n    print(f\"Primary location: {result.primary_location.concept_text}\")\n    print(f\"  ID: {result.primary_location.concept_id}\")\n    print(f\"  Table: {result.primary_location.table_name}\")\n    \n    if result.alternate_locations:\n        print(\"\\nAlternate locations:\")\n        for alt in result.alternate_locations:\n            print(f\"  - {alt.concept_text} ({alt.table_name})\")\n    \n    print(f\"\\nReasoning: {result.reasoning}\")\n    \n    # Convert to IndexCode for use in finding models\n    index_code = result.primary_location.as_index_code()\n    print(f\"\\nAs IndexCode: {index_code.system}:{index_code.code}\")\n    \n    return result\n\nresult = asyncio.run(find_locations())\n# Output:\n# Primary location: Posterior cruciate ligament\n#   ID: RID2905\n#   Table: radlex\n# \n# Alternate locations:\n#   - Knee joint (anatomic_locations)\n#   - Cruciate ligament structure (snomedct)\n# \n# Reasoning: Selected \"Posterior cruciate ligament\" as the primary location because...\n# \n# As IndexCode: RADLEX:RID2905\n```\n\n**Key Features:**\n- **Two-agent architecture**: Search agent finds candidates, matching agent selects best options\n- **Multiple ontology sources**: Searches across anatomic_locations, RadLex, and SNOMED-CT\n- **Intelligent selection**: Finds the \"sweet spot\" of specificity - specific enough to be accurate but general enough to be useful\n- **Reusable components**: `LanceDBOntologySearchClient` can be used for other ontology searches\n- **Production ready**: Proper error handling, logging, and connection lifecycle management\n\n### `match_ontology_concepts()`\n\nHigh-performance search for relevant medical concepts across multiple ontology databases. Supports both LanceDB vector search and BioOntology REST API through a flexible Protocol-based architecture.\n\n```python\nimport asyncio\nfrom findingmodel.tools.ontology_concept_match import match_ontology_concepts\n\nasync def search_concepts():\n    # Automatically uses all configured backends (LanceDB and/or BioOntology)\n    result = await match_ontology_concepts(\n        finding_name=\"pneumonia\",\n        finding_description=\"Inflammation of lung parenchyma\",  # Optional\n        exclude_anatomical=True  # Exclude anatomical structures (default: True)\n    )\n    \n    print(f\"Exact matches ({len(result.exact_matches)}):\")\n    for concept in result.exact_matches:\n        print(f\"  - {concept.code}: {concept.text}\")\n    \n    print(f\"\\nShould include ({len(result.should_include)}):\")\n    for concept in result.should_include:\n        print(f\"  - {concept.code}: {concept.text}\")\n    \n    print(f\"\\nMarginal relevance ({len(result.marginal)}):\")\n    for concept in result.marginal:\n        print(f\"  - {concept.code}: {concept.text}\")\n    \n    return result\n\nresult = asyncio.run(search_concepts())\n# Output:\n# Exact matches (5):\n#   - RID5350: pneumonia\n#   - 233604007: Pneumonia\n#   - RID34769: viral pneumonia\n#   - 53084003: Bacterial pneumonia\n#   - RID3541: pneumonitis\n# \n# Should include (3):\n#   - RID5351: lobar pneumonia\n#   - RID5352: bronchopneumonia\n#   - 233607000: Atypical pneumonia\n# \n# Marginal relevance (2):\n#   - RID4866: pulmonary edema\n#   - RID34637: bronchitis\n```\n\n**Key Features:**\n- **Multi-backend support**: Automatically uses LanceDB and/or BioOntology based on configuration\n- **Protocol-based architecture**: Clean abstraction allows easy addition of new search providers\n- **High performance**: ~10 second searches with parallel backend execution\n- **Guaranteed exact matches**: Post-processing ensures exact name matches are never missed\n- **Smart categorization**: Three tiers - exact matches, should include, marginal\n- **Excludes anatomy**: Focuses on diseases/conditions (use `find_anatomic_locations()` for anatomy)\n\n### `edit_model_natural_language()` and `edit_model_markdown()`\n\nAI-powered editing tools for finding models with two modes: natural language commands and Markdown-based editing. Both preserve existing OIFM IDs and only allow safe additions and non-semantic text changes.\n\n```python\nimport asyncio\nfrom findingmodel import FindingModelFull\nfrom findingmodel.tools.model_editor import (\n    edit_model_natural_language,\n    edit_model_markdown,\n    export_model_for_editing\n)\n\nasync def edit_with_natural_language():\n    # Load an existing model\n    with open(\"pneumothorax.fm.json\") as f:\n        model = FindingModelFull.model_validate_json(f.read())\n    \n    # Add a new attribute using natural language\n    result = await edit_model_natural_language(\n        model=model,\n        command=\"Add severity attribute with values mild, moderate, severe\"\n    )\n    \n    # Check for any rejected changes\n    if result.rejections:\n        print(\"Some changes were rejected:\")\n        for rejection in result.rejections:\n            print(f\"  - {rejection}\")\n    \n    # The updated model with new attribute\n    updated_model = result.model\n    print(f\"Model now has {len(updated_model.attributes)} attributes\")\n    \n    return result\n\nasync def edit_with_markdown():\n    # Load an existing model\n    with open(\"pneumothorax.fm.json\") as f:\n        model = FindingModelFull.model_validate_json(f.read())\n    \n    # Export to editable Markdown format\n    markdown_content = export_model_for_editing(model)\n    print(\"Current Markdown:\")\n    print(markdown_content)\n    \n    # Add new attribute section to the markdown\n    edited_markdown = markdown_content + \"\"\"\n### severity\n\nSeverity of the pneumothorax\n\n- mild: Small pneumothorax with minimal clinical impact\n- moderate: Medium-sized pneumothorax requiring monitoring\n- severe: Large pneumothorax requiring immediate intervention\n\n\"\"\"\n    \n    # Apply the Markdown edits\n    result = await edit_model_markdown(\n        model=model,\n        edited_markdown=edited_markdown\n    )\n    \n    # Check results\n    if result.rejections:\n        print(\"Some changes were rejected:\")\n        for rejection in result.rejections:\n            print(f\"  - {rejection}\")\n    \n    updated_model = result.model\n    print(f\"Model now has {len(updated_model.attributes)} attributes\")\n    \n    return result\n\n# Run examples\nnl_result = asyncio.run(edit_with_natural_language())\nmd_result = asyncio.run(edit_with_markdown())\n```\n\n**Safety Features:**\n- **ID preservation**: All existing OIFM IDs (model, attribute, value) are preserved\n- **Safe changes only**: Only allows adding new attributes/values or editing non-semantic text\n- **Rejection feedback**: Clear explanations when changes are rejected as unsafe\n- **Validation**: Built-in validation ensures model integrity and proper ID generation\n\n**Editable Markdown Format:**\n```markdown\n# Model Name\n\nModel description here.\n\nSynonyms: synonym1, synonym2\n\n## Attributes\n\n### attribute_name\n\nOptional attribute description\n\n- value1: Optional value description\n- value2: Another value\n- value3\n\n### another_attribute\n\n- option1\n- option2\n```\n\n**Use Cases:**\n- **Natural Language**: \"Add location attribute with upper, middle, lower lobe options\"\n- **Markdown**: Direct editing of exported model structure with full control over formatting\n- **Collaborative**: Export to Markdown, share with clinical experts, import their edits\n- **Batch editing**: Multiple attribute additions in a single Markdown edit session\n",
    "bugtrack_url": null,
    "license": null,
    "summary": "Definition and tools for Open Imaging Finding Models",
    "version": "0.5.0",
    "project_urls": {
        "Homepage": "https://github.com/openimagingdata/findingmodel",
        "Issues": "https://github.com/openimagingdata/findingmodel/issues"
    },
    "split_keywords": [
        "finding model",
        " common data element",
        " medical imaging",
        " data model",
        " radiology"
    ],
    "urls": [
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "20f3e67b670e597bd96e2f36a1abf63028d9afc3bb4105d2141cff36ed78fb51",
                "md5": "a0505032a61f5e0471ca3ad3a91e7905",
                "sha256": "5ed1440884b500f528b1012568d48468271382cc8b1304acc4ece6217b4ae034"
            },
            "downloads": -1,
            "filename": "findingmodel-0.5.0-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "a0505032a61f5e0471ca3ad3a91e7905",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.11",
            "size": 107848,
            "upload_time": "2025-11-03T20:24:39",
            "upload_time_iso_8601": "2025-11-03T20:24:39.772915Z",
            "url": "https://files.pythonhosted.org/packages/20/f3/e67b670e597bd96e2f36a1abf63028d9afc3bb4105d2141cff36ed78fb51/findingmodel-0.5.0-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "b0dd4eb8bfedffc1c8ccb9e8442d2ecb082a1c76dc4f2b7d8db12d5e2f70ae51",
                "md5": "b2bb953cef29c8ec7b182b0486c0e8bc",
                "sha256": "d62d8deacbd9d901e113a48066e2aa149603736ed171b85fd717ccdb69bce150"
            },
            "downloads": -1,
            "filename": "findingmodel-0.5.0.tar.gz",
            "has_sig": false,
            "md5_digest": "b2bb953cef29c8ec7b182b0486c0e8bc",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.11",
            "size": 100136,
            "upload_time": "2025-11-03T20:24:40",
            "upload_time_iso_8601": "2025-11-03T20:24:40.843156Z",
            "url": "https://files.pythonhosted.org/packages/b0/dd/4eb8bfedffc1c8ccb9e8442d2ecb082a1c76dc4f2b7d8db12d5e2f70ae51/findingmodel-0.5.0.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2025-11-03 20:24:40",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "openimagingdata",
    "github_project": "findingmodel",
    "travis_ci": false,
    "coveralls": false,
    "github_actions": false,
    "lcname": "findingmodel"
}
        
Elapsed time: 1.48777s