Initial commit
Some checks failed
CI/CD Pipeline / Code Quality & Linting (push) Has been cancelled
CI/CD Pipeline / Policy Validation (push) Has been cancelled
CI/CD Pipeline / Test Suite (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-firm-connectors) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-forms) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-hmrc) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ingestion) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-normalize-map) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ocr) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-indexer) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-reason) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rpa) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (ui-review) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (ui-review) (push) Has been cancelled
CI/CD Pipeline / Generate SBOM (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Notifications (push) Has been cancelled

This commit is contained in:
harkon
2025-10-11 08:41:36 +01:00
commit b324ff09ef
276 changed files with 55220 additions and 0 deletions

418
libs/coverage/evaluator.py Normal file
View File

@@ -0,0 +1,418 @@
"""Core coverage evaluation engine."""
from datetime import datetime
from typing import Any
import structlog
from ..schemas import (
BlockingItem,
Citation,
CompiledCoveragePolicy,
CoverageItem,
CoverageReport,
FoundEvidence,
OverallStatus,
Role,
ScheduleCoverage,
Status,
)
logger = structlog.get_logger()
class CoverageEvaluator:
"""Core coverage evaluation engine"""
def __init__(self, kg_client: Any = None, rag_client: Any = None):
self.kg_client = kg_client
self.rag_client = rag_client
async def check_document_coverage(
self,
taxpayer_id: str,
tax_year: str,
policy: CompiledCoveragePolicy,
) -> CoverageReport:
"""Main coverage evaluation workflow"""
logger.info(
"Starting coverage evaluation",
taxpayer_id=taxpayer_id,
tax_year=tax_year,
policy_version=policy.policy.version,
)
# Step A: Infer required schedules
required_schedules = await self.infer_required_schedules(
taxpayer_id, tax_year, policy
)
# Step B: Evaluate each schedule
schedule_coverage = []
all_blocking_items = []
for schedule_id in required_schedules:
coverage = await self._evaluate_schedule_coverage(
schedule_id, taxpayer_id, tax_year, policy
)
schedule_coverage.append(coverage)
# Collect blocking items
for evidence in coverage.evidence:
if evidence.role == Role.REQUIRED and evidence.status == Status.MISSING:
all_blocking_items.append(
BlockingItem(schedule_id=schedule_id, evidence_id=evidence.id)
)
# Step C: Determine overall status
overall_status = self._determine_overall_status(
schedule_coverage, all_blocking_items
)
return CoverageReport(
tax_year=tax_year,
taxpayer_id=taxpayer_id,
schedules_required=required_schedules,
overall_status=overall_status,
coverage=schedule_coverage,
blocking_items=all_blocking_items,
policy_version=policy.policy.version,
)
async def infer_required_schedules(
self,
taxpayer_id: str,
tax_year: str,
policy: CompiledCoveragePolicy,
) -> list[str]:
"""Determine which schedules are required for this taxpayer"""
required = []
for schedule_id, trigger in policy.policy.triggers.items():
is_required = False
# Check any_of conditions
if trigger.any_of:
for condition in trigger.any_of:
predicate = policy.compiled_predicates.get(condition)
if predicate and predicate(taxpayer_id, tax_year):
is_required = True
break
# Check all_of conditions
if trigger.all_of and not is_required:
all_match = True
for condition in trigger.all_of:
predicate = policy.compiled_predicates.get(condition)
if not predicate or not predicate(taxpayer_id, tax_year):
all_match = False
break
if all_match:
is_required = True
if is_required:
required.append(schedule_id)
logger.debug(
"Schedule required",
schedule_id=schedule_id,
taxpayer_id=taxpayer_id,
)
return required
async def find_evidence_docs(
self,
taxpayer_id: str,
tax_year: str,
evidence_ids: list[str],
policy: CompiledCoveragePolicy,
) -> dict[str, list[FoundEvidence]]:
"""Find evidence documents in the knowledge graph"""
if not self.kg_client:
logger.warning("No KG client available, returning empty evidence")
empty_evidence_list: list[FoundEvidence] = []
return dict.fromkeys(evidence_ids, empty_evidence_list)
# Import here to avoid circular imports
from ..neo import kg_find_evidence
evidence_map: dict[str, list[FoundEvidence]] = {}
thresholds = policy.policy.defaults.confidence_thresholds
for evidence_id in evidence_ids:
try:
found = await kg_find_evidence(
self.kg_client,
taxpayer_id=taxpayer_id,
tax_year=tax_year,
kinds=[evidence_id],
min_ocr=thresholds.get("ocr", 0.6),
date_window=policy.policy.defaults.date_tolerance_days,
)
evidence_map[evidence_id] = found
except Exception as e:
logger.error(
"Failed to find evidence",
evidence_id=evidence_id,
error=str(e),
)
empty_list: list[FoundEvidence] = []
evidence_map[evidence_id] = empty_list
return evidence_map
def classify_status(
self,
found: list[FoundEvidence],
policy: CompiledCoveragePolicy,
tax_year: str,
) -> Status:
"""Classify evidence status based on what was found"""
if not found:
return Status.MISSING
classifier = policy.policy.status_classifier
tax_year_start, tax_year_end = self._parse_tax_year_bounds(
policy.policy.tax_year_boundary.start,
policy.policy.tax_year_boundary.end,
)
# Check for conflicts first
if len(found) > 1:
# Simple conflict detection: different totals for same period
# In production, this would be more sophisticated
return Status.CONFLICTING
evidence = found[0]
# Check if evidence meets verified criteria
if (
evidence.ocr_confidence >= classifier.present_verified.min_ocr
and evidence.extract_confidence >= classifier.present_verified.min_extract
):
# Check date validity
if evidence.date:
# Handle both date-only and datetime strings consistently
if "T" not in evidence.date:
# Date-only string, add time and timezone (middle of day)
evidence_date = datetime.fromisoformat(
evidence.date + "T12:00:00+00:00"
)
else:
# Full datetime string, ensure timezone-aware
evidence_date = datetime.fromisoformat(
evidence.date.replace("Z", "+00:00")
)
if tax_year_start <= evidence_date <= tax_year_end:
return Status.PRESENT_VERIFIED
# Check if evidence meets unverified criteria
if (
evidence.ocr_confidence >= classifier.present_unverified.min_ocr
and evidence.extract_confidence >= classifier.present_unverified.min_extract
):
return Status.PRESENT_UNVERIFIED
# Default to missing if confidence too low
return Status.MISSING
async def build_reason_and_citations(
self,
schedule_id: str,
evidence_item: Any,
status: Status,
taxpayer_id: str,
tax_year: str,
policy: CompiledCoveragePolicy,
) -> tuple[str, list[Citation]]:
"""Build human-readable reason and citations"""
# Build reason text
reason = self._build_reason_text(evidence_item, status, policy)
# Get citations from KG
citations = []
if self.kg_client:
try:
from ..neo import kg_rule_citations
kg_citations = await kg_rule_citations(
self.kg_client, schedule_id, evidence_item.boxes
)
citations.extend(kg_citations)
except Exception as e:
logger.warning("Failed to get KG citations", error=str(e))
# Fallback to RAG citations if needed
if not citations and self.rag_client:
try:
from ..rag import rag_search_for_citations
query = f"{schedule_id} {evidence_item.id} requirements"
filters = {
"jurisdiction": policy.policy.jurisdiction,
"tax_year": tax_year,
"pii_free": True,
}
rag_citations = await rag_search_for_citations(
self.rag_client, query, filters
)
citations.extend(rag_citations)
except Exception as e:
logger.warning("Failed to get RAG citations", error=str(e))
return reason, citations
async def _evaluate_schedule_coverage(
self,
schedule_id: str,
taxpayer_id: str,
tax_year: str,
policy: CompiledCoveragePolicy,
) -> ScheduleCoverage:
"""Evaluate coverage for a single schedule"""
schedule_policy = policy.policy.schedules[schedule_id]
evidence_items = []
# Get all evidence IDs for this schedule
evidence_ids = [e.id for e in schedule_policy.evidence]
# Find evidence in KG
evidence_map = await self.find_evidence_docs(
taxpayer_id, tax_year, evidence_ids, policy
)
# Evaluate each evidence requirement
for evidence_req in schedule_policy.evidence:
# Check if conditionally required evidence applies
if (
evidence_req.role == Role.CONDITIONALLY_REQUIRED
and evidence_req.condition
):
predicate = policy.compiled_predicates.get(evidence_req.condition)
if not predicate or not predicate(taxpayer_id, tax_year):
continue # Skip this evidence as condition not met
found = evidence_map.get(evidence_req.id, [])
status = self.classify_status(found, policy, tax_year)
reason, citations = await self.build_reason_and_citations(
schedule_id, evidence_req, status, taxpayer_id, tax_year, policy
)
evidence_item = CoverageItem(
id=evidence_req.id,
role=evidence_req.role,
status=status,
boxes=evidence_req.boxes,
found=found,
acceptable_alternatives=evidence_req.acceptable_alternatives,
reason=reason,
citations=citations,
)
evidence_items.append(evidence_item)
# Determine schedule status
schedule_status = self._determine_schedule_status(evidence_items)
return ScheduleCoverage(
schedule_id=schedule_id,
status=schedule_status,
evidence=evidence_items,
)
def _determine_overall_status(
self,
schedule_coverage: list[ScheduleCoverage],
blocking_items: list[BlockingItem],
) -> OverallStatus:
"""Determine overall coverage status"""
if blocking_items:
return OverallStatus.BLOCKING
# Check if all schedules are OK
all_ok = all(s.status == OverallStatus.OK for s in schedule_coverage)
if all_ok:
return OverallStatus.OK
return OverallStatus.PARTIAL
def _determine_schedule_status(
self, evidence_items: list[CoverageItem]
) -> OverallStatus:
"""Determine status for a single schedule"""
# Check for blocking issues
has_missing_required = any(
e.role == Role.REQUIRED and e.status == Status.MISSING
for e in evidence_items
)
if has_missing_required:
return OverallStatus.BLOCKING
# Check for partial issues
has_unverified = any(
e.status == Status.PRESENT_UNVERIFIED for e in evidence_items
)
if has_unverified:
return OverallStatus.PARTIAL
return OverallStatus.OK
def _build_reason_text(
self,
evidence_item: Any,
status: Status,
policy: CompiledCoveragePolicy,
) -> str:
"""Build human-readable reason text"""
evidence_id = evidence_item.id
# Get reason from policy if available
if evidence_item.reasons and "short" in evidence_item.reasons:
base_reason = evidence_item.reasons["short"]
else:
base_reason = f"{evidence_id} is required for this schedule."
# Add status-specific details
if status == Status.MISSING:
return f"No {evidence_id} found. {base_reason}"
elif status == Status.PRESENT_UNVERIFIED:
return (
f"{evidence_id} present but confidence below threshold. {base_reason}"
)
elif status == Status.CONFLICTING:
return f"Conflicting {evidence_id} documents found. {base_reason}"
else:
return f"{evidence_id} verified. {base_reason}"
def _parse_tax_year_bounds(
self, start_str: str, end_str: str
) -> tuple[datetime, datetime]:
"""Parse tax year boundary strings to datetime objects"""
# Handle both date-only and datetime strings
if "T" not in start_str:
# Date-only string, add time and timezone
start = datetime.fromisoformat(start_str + "T00:00:00+00:00")
else:
# Full datetime string, ensure timezone-aware
start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
if "T" not in end_str:
# Date-only string, add time and timezone (end of day)
end = datetime.fromisoformat(end_str + "T23:59:59+00:00")
else:
# Full datetime string, ensure timezone-aware
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
return start, end