Backend Analyzer Framework¶
Overview¶
The OpenContracts backend supports two distinct analyzer frameworks for processing documents and creating annotations:
- Task-based Analyzers - Modern framework running within the Django application
- Gremlin-based Analyzers - Legacy framework using external microservice engines
This document details how the backend handles analysis requests from frontend mutations, executes analyzers, and processes results.
GraphQL Mutation Entry Point¶
StartDocumentAnalysisMutation¶
File: /config/graphql/mutations.py
The primary entry point for triggering analyses is the StartDocumentAnalysisMutation GraphQL mutation, exposed as startAnalysisOnDoc:
class StartDocumentAnalysisMutation(graphene.Mutation):
class Arguments:
document_id = graphene.ID(required=False, description="Id of the document to be analyzed.")
analyzer_id = graphene.ID(required=True, description="Id of the analyzer to use.")
corpus_id = graphene.ID(required=False, description="Optional Id of the corpus to associate with the analysis.")
analysis_input_data = GenericScalar(required=False, description="Optional arguments to be passed to the analyzer.")
ok = graphene.Boolean()
message = graphene.String()
obj = graphene.Field(AnalysisType)
Key Features: - Flexible Targeting: Can analyze single documents (document_id) or entire corpuses (corpus_id) - Custom Configuration: Supports analysis_input_data for analyzers requiring user input - Permission Validation: Checks user permissions for documents and corpuses - Error Handling: Returns structured success/failure responses
Permission Logic:
# Document permission check
if document_pk:
document = Document.objects.get(pk=document_pk)
if not (document.creator == user or document.is_public):
raise PermissionError("You don't have permission to analyze this document.")
# Corpus permission check
if corpus_pk:
corpus = Corpus.objects.get(pk=corpus_pk)
if not (corpus.creator == user or corpus.is_public):
raise PermissionError("You don't have permission to analyze this corpus.")
Analysis Orchestration¶
process_analyzer Function¶
File: /opencontractserver/tasks/corpus_tasks.py
The mutation delegates to process_analyzer(), which orchestrates analysis execution:
def process_analyzer(
user_id: int | str,
analyzer: Analyzer | None,
corpus_id: str | int | None = None,
document_ids: list[str | int] | None = None,
corpus_action: CorpusAction | None = None,
analysis_input_data: dict | None = None,
) -> Analysis:
Process Flow: 1. Analysis Creation: Creates Analysis record with metadata tracking 2. Framework Detection: Routes to appropriate analyzer framework based on analyzer.task_name 3. Task Dispatch: Queues Celery tasks for asynchronous execution 4. Transaction Safety: Uses transaction.on_commit() to ensure database consistency
Analysis Creation and Setup¶
File: /opencontractserver/utils/analysis.py
def create_and_setup_analysis(analyzer, user_id, corpus_id=None, doc_ids=None, corpus_action=None):
Key Operations: - Deduplication: Reuses existing Analysis records when possible - Metadata Tracking: Sets analysis_started timestamp - Permission Setup: Grants CRUD permissions to creator - Document Association: Links specific documents for targeted analysis
Analyzer Frameworks¶
1. Task-based Analyzers (Modern Framework)¶
Architecture: Runs as Celery tasks within the main Django application
Analyzer Model Structure¶
File: /opencontractserver/analyzer/models.py
class Analyzer(BaseOCModel):
id = CharField(max_length=1024, primary_key=True)
manifest = NullableJSONField(default=jsonfield_default_value, null=True, blank=True)
description = TextField(null=False, blank=True, default="")
disabled = BooleanField(default=False)
is_public = BooleanField(default=True)
icon = FileField(blank=True, upload_to=calculate_analyzer_icon_path)
# Framework Selection Fields (mutually exclusive)
host_gremlin = ForeignKey(GremlinEngine, null=True, blank=True) # For Gremlin framework
task_name = CharField(max_length=1024, null=True, blank=True) # For task framework
# Configuration Support
input_schema = NullableJSONField(null=True, blank=True, help_text="Optional JSONSchema describing the analyzer input.")
Database Constraints: - Mutual Exclusivity: Either host_gremlin OR task_name must be set (not both) - Uniqueness: Each task_name and host_gremlin must be unique
Task Execution Flow¶
File: /opencontractserver/tasks/corpus_tasks.py
@shared_task
def run_task_name_analyzer(
analysis_id: int | str,
document_ids: list[str | int] | None = None,
analysis_input_data: dict | None = None,
):
Process: 1. Task Resolution: get_doc_analyzer_task_by_name(task_name) finds registered Celery task 2. Document Batching: Creates Celery chord for parallel document processing 3. Custom Input: Passes analysis_input_data to each task instance 4. Completion Tracking: Aggregates results via mark_analysis_complete callback
@doc_analyzer_task Decorator¶
File: /opencontractserver/shared/decorators.py
The @doc_analyzer_task decorator provides standardized infrastructure for analyzer tasks:
Features: - Document Validation: Ensures documents and analysis exist - Lock Management: Retries when documents are backend-locked with exponential backoff - Data Preparation: Extracts PDF text, PAWLS parse data, and translation layers - Result Processing: Handles annotation creation and database persistence - Error Handling: Captures exceptions and stores in Analysis records - Schema Support: Accepts input_schema for frontend configuration forms
Function Signature:
@doc_analyzer_task(max_retries=3, input_schema={...})
def my_analyzer(pdf_text_extract, pdf_pawls_extract, doc_id, analysis_id, corpus_id, **kwargs):
"""
Returns: (doc_annotations, span_label_pairs, metadata, task_pass, message)
"""
return ([], [(span, "ENTITY")], [], True, "Analysis completed successfully")
Expected Return Format:
# 5-tuple return format
(
doc_annotations: List[str], # Document-level labels
span_label_pairs: List[Tuple[TextSpan, str]], # Text spans with labels
metadata: List[Dict[str, Any]], # Additional task metadata
task_pass: bool, # Success/failure indicator
message: str # Human-readable result message
)
Annotation Processing¶
The decorator automatically handles annotation creation:
PDF Documents (Token-based):
# Uses PAWLS translation layer for precise coordinates
annotation_data = pdf_data_layer.create_opencontract_annotation_from_span({
"span": span,
"annotation_label": label_text
})
Text Documents (Span-based):
# Simple start/end character offsets
annot = Annotation(
raw_text=pdf_text_extract[span["start"]:span["end"]],
json={"start": span["start"], "end": span["end"]},
annotation_type=LabelType.SPAN_LABEL
)
Example Task-based Analyzer¶
@doc_analyzer_task(
input_schema={
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"confidence_threshold": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"default": 0.7
},
"entity_types": {
"type": "array",
"items": {"type": "string"},
"default": ["PERSON", "ORG", "MONEY"]
}
}
}
)
def entity_extractor(pdf_text_extract, pdf_pawls_extract, **kwargs):
"""Extract named entities using configurable parameters."""
# Get user configuration
confidence = kwargs.get('confidence_threshold', 0.7)
entity_types = kwargs.get('entity_types', ['PERSON', 'ORG', 'MONEY'])
# Run NLP model
entities = nlp_model.extract_entities(
pdf_text_extract,
confidence_threshold=confidence,
entity_types=entity_types
)
# Format spans
span_label_pairs = [
({"start": ent.start, "end": ent.end, "text": ent.text}, ent.label)
for ent in entities
]
return ([], span_label_pairs, [], True, f"Found {len(entities)} entities")
2. Gremlin-based Analyzers (Legacy Framework)¶
Architecture: External microservice engines running analyzers
GremlinEngine Model¶
class GremlinEngine(BaseOCModel):
url = CharField(max_length=1024) # Engine endpoint URL
api_key = CharField(max_length=1024) # Authentication key
last_synced = DateTimeField() # Last sync timestamp
install_started = DateTimeField() # Installation tracking
install_completed = DateTimeField()
is_public = BooleanField(default=True)
Execution Flow¶
File: /opencontractserver/utils/analyzer.py
def run_analysis(analysis_id: str, doc_ids: list[int | str] | None = None) -> int:
Process: 1. Document Packaging: Bundles document URLs (PDF, text extract, PAWLS parse) 2. Submission Payload: Creates analysis job request 3. HTTP Dispatch: POSTs job to Gremlin engine endpoint 4. Callback Setup: Registers callback URL for result notification
Submission Format:
gremlin_submission = {
"analyzer_id": analyzer.id,
"callback_url": f"{settings.CALLBACK_ROOT_URL_FOR_ANALYZER}/analysis/{analysis.id}/complete",
"callback_token": analysis.callback_token.__str__(),
"documents": [
{
"original_id": doc.id,
"pdf_file_url": get_django_file_field_url("pdf_file", doc),
"txt_extract_file_url": get_django_file_field_url("txt_extract_file", doc),
"pawls_parse_file_url": get_django_file_field_url("pawls_parse_file", doc)
}
for doc in docs
]
}
Security Features: - Callback Tokens: UUID-based authentication for result callbacks - URL Generation: Handles both AWS S3 and local file serving
Analysis Lifecycle¶
1. Analysis Record¶
File: /opencontractserver/analyzer/models.py
class Analysis(BaseOCModel):
# Core References
analyzer = ForeignKey(Analyzer, on_delete=CASCADE)
analyzed_corpus = ForeignKey(Corpus, null=True, blank=True)
analyzed_documents = ManyToManyField(Document, related_name="included_in_analyses")
corpus_action = ForeignKey(CorpusAction, null=True, blank=True)
# Security & Callbacks
callback_token = UUIDField(default=uuid.uuid4, editable=False)
received_callback_file = FileField()
# Execution Tracking
analysis_started = DateTimeField(null=True, blank=True)
analysis_completed = DateTimeField(null=True, blank=True)
status = CharField(choices=[(status.value, status.name) for status in JobStatus])
# Results & Errors
result_message = TextField(null=True, blank=True)
error_message = TextField(null=True, blank=True)
error_traceback = TextField(null=True, blank=True)
import_log = TextField(null=True, blank=True)
2. Document Analysis Tracking¶
File: /opencontractserver/documents/models.py
class DocumentAnalysisRow(BaseOCModel):
"""Tracks per-document analysis results within an Analysis."""
document = ForeignKey(Document, on_delete=CASCADE)
analysis = ForeignKey(Analysis, on_delete=CASCADE)
annotations = ManyToManyField(Annotation, related_name="created_by_analysis_row")
3. Completion Handling¶
Task Framework:
@shared_task
def mark_analysis_complete(analysis_id: str | int, doc_ids: list[int | str]) -> None:
analysis = Analysis.objects.get(pk=analysis_id)
analysis.analysis_completed = timezone.now()
analysis.analyzed_documents.add(*doc_ids)
analysis.save()
Gremlin Framework: - External engines POST results to callback URLs - Results processed via Django views handling callback tokens - Annotations imported from standardized JSON format
Error Handling and Reliability¶
Task Framework Error Handling¶
Retry Logic:
# Document lock retry with exponential backoff
if doc.backend_lock:
retry_count = self.request.retries
delay = min(INITIAL_DELAY + (retry_count * DELAY_INCREMENT), MAX_DELAY)
if delay < MAX_DELAY:
raise self.retry(countdown=delay)
Error Capture:
try:
result = func(...)
if task_pass:
analysis.result_message = message
else:
analysis.error_message = message
except Exception as e:
analysis.error_message = str(e)
analysis.error_traceback = traceback.format_exc()
return [], [], [{"data": {"error": str(e)}}], False, str(e)
Gremlin Framework Error Handling¶
- Network Failures: HTTP request timeouts and connection errors
- Callback Validation: Token-based authentication for result submissions
- Malformed Results: Validation of returned annotation data
Configuration and Extensibility¶
Input Schema Support¶
Analyzers can define JSON Schema for frontend configuration:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"model_name": {
"type": "string",
"enum": ["roberta-base", "bert-large"],
"default": "roberta-base"
},
"confidence_threshold": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"default": 0.8
}
},
"required": ["model_name"]
}
Label Management¶
Automatic Label Creation: - Task framework creates AnnotationLabel objects automatically - Labels linked to originating Analyzer for tracking - Supports document-level (DOC_TYPE_LABEL) and span-level (TOKEN_LABEL, SPAN_LABEL) annotations
Permission System¶
Analysis Permissions: - Analyses inherit creator permissions - Support for public/private visibility - Per-analysis permission grants via Django Guardian
Document Access: - Analyzers can only process documents user has access to - Supports both owned and publicly accessible documents - Corpus-level permissions required for corpus analysis
Performance and Scaling¶
Parallel Processing¶
Task Framework:
# Parallel document processing using Celery chord
chord(
group([
task_func.s(doc_id=doc_id, analysis_id=analysis.id, **analysis_input_data)
for doc_id in document_ids
])
)(mark_analysis_complete.si(analysis_id=analysis.id, doc_ids=document_ids))
Resource Management¶
- Backend Locking: Prevents concurrent processing of same document
- Celery Queues: Distributes work across worker processes
- Database Transactions: Ensures consistency during annotation creation
Monitoring and Observability¶
- Analysis Status Tracking:
CREATED,RUNNING,COMPLETED,FAILED - Execution Timestamps: Start/completion tracking for performance analysis
- Error Logging: Detailed error messages and stack traces
- Result Metrics: Annotation counts and processing statistics
This backend analyzer framework provides a robust, scalable foundation for document analysis workflows.