# `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"
}