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

View File

@@ -0,0 +1,53 @@
# Multi-stage build for svc-coverage
FROM python:3.12-slim AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy requirements and install dependencies
COPY libs/requirements-base.txt /tmp/libs-requirements.txt
COPY apps/svc_coverage/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /tmp/libs-requirements.txt -r /tmp/requirements.txt
# Production stage
FROM python:3.12-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r appuser \
&& useradd -r -g appuser appuser
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Set working directory
WORKDIR /app
# Copy application code
COPY libs/ ./libs/
COPY apps/svc_coverage/ ./apps/svc_coverage/
# Create non-root user and set permissions
RUN chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/healthz || exit 1
# Expose port
EXPOSE 8000
# Run the application
CMD ["python", "-m", "uvicorn", "apps.svc_coverage.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1 @@
"""Coverage service package."""

View File

@@ -0,0 +1,112 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version number format
version_num_format = %04d
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
# behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql://user:pass@localhost:5432/coverage
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,92 @@
"""Alembic environment configuration for coverage service."""
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add the parent directory to the path so we can import our models
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
# Import your models here
from apps.svc_coverage.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_url():
"""Get database URL from environment or config."""
return os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url"))
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,76 @@
"""Initial coverage tables
Revision ID: 0001
Revises:
Create Date: 2024-09-14 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create coverage_versions table
op.create_table(
'coverage_versions',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('version', sa.String(length=50), nullable=False),
sa.Column('jurisdiction', sa.String(length=10), nullable=False),
sa.Column('tax_year', sa.String(length=10), nullable=False),
sa.Column('tenant_id', sa.String(length=100), nullable=True),
sa.Column('source_files', postgresql.JSON(astext_type=sa.Text()), nullable=False),
sa.Column('compiled_at', sa.DateTime(), nullable=False),
sa.Column('hash', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for coverage_versions
op.create_index('ix_coverage_versions_version', 'coverage_versions', ['version'])
op.create_index('ix_coverage_versions_jurisdiction_tax_year', 'coverage_versions', ['jurisdiction', 'tax_year'])
op.create_index('ix_coverage_versions_tenant_id', 'coverage_versions', ['tenant_id'])
op.create_index('ix_coverage_versions_hash', 'coverage_versions', ['hash'])
# Create coverage_audit table
op.create_table(
'coverage_audit',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('taxpayer_id', sa.String(length=100), nullable=False),
sa.Column('tax_year', sa.String(length=10), nullable=False),
sa.Column('policy_version', sa.String(length=50), nullable=False),
sa.Column('overall_status', sa.String(length=20), nullable=False),
sa.Column('blocking_items', postgresql.JSON(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('trace_id', sa.String(length=100), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for coverage_audit
op.create_index('ix_coverage_audit_taxpayer_id', 'coverage_audit', ['taxpayer_id'])
op.create_index('ix_coverage_audit_tax_year', 'coverage_audit', ['tax_year'])
op.create_index('ix_coverage_audit_taxpayer_tax_year', 'coverage_audit', ['taxpayer_id', 'tax_year'])
op.create_index('ix_coverage_audit_created_at', 'coverage_audit', ['created_at'])
op.create_index('ix_coverage_audit_trace_id', 'coverage_audit', ['trace_id'])
def downgrade() -> None:
# Drop coverage_audit table and indexes
op.drop_index('ix_coverage_audit_trace_id', table_name='coverage_audit')
op.drop_index('ix_coverage_audit_created_at', table_name='coverage_audit')
op.drop_index('ix_coverage_audit_taxpayer_tax_year', table_name='coverage_audit')
op.drop_index('ix_coverage_audit_tax_year', table_name='coverage_audit')
op.drop_index('ix_coverage_audit_taxpayer_id', table_name='coverage_audit')
op.drop_table('coverage_audit')
# Drop coverage_versions table and indexes
op.drop_index('ix_coverage_versions_hash', table_name='coverage_versions')
op.drop_index('ix_coverage_versions_tenant_id', table_name='coverage_versions')
op.drop_index('ix_coverage_versions_jurisdiction_tax_year', table_name='coverage_versions')
op.drop_index('ix_coverage_versions_version', table_name='coverage_versions')
op.drop_table('coverage_versions')

523
apps/svc_coverage/main.py Normal file
View File

@@ -0,0 +1,523 @@
# FILE: apps/svc-coverage/main.py
# Coverage policy service with evaluation, clarification, and hot reload
import os
import sys
from typing import Any
import structlog
from fastapi import Depends, HTTPException
from pydantic import BaseModel
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
from libs.app_factory import create_app
from libs.config import BaseAppSettings, create_event_bus, create_neo4j_client
from libs.coverage import CoverageEvaluator
from libs.events import EventBus
from libs.neo import Neo4jClient
from libs.observability import get_metrics, get_tracer, setup_observability
from libs.policy import PolicyLoader, get_policy_loader
from libs.schemas import (
ClarifyContext,
ClarifyResponse,
CoverageGap,
CoverageReport,
PolicyError,
UploadOption,
ValidationResult,
)
from libs.security import get_current_user, get_tenant_id
logger = structlog.get_logger()
async def http_exception_handler(_request, exc) -> dict[str, str | int]:
"""Handle HTTP exceptions"""
return {"detail": exc.detail, "status_code": exc.status_code}
class CoverageSettings(BaseAppSettings):
"""Settings for Coverage service"""
service_name: str = "svc-coverage"
# Policy configuration
config_dir: str = "config"
policy_reload_enabled: bool = True
# Database
postgres_url: str = "postgresql://user:pass@localhost:5432/coverage"
# External services
rag_service_url: str = "http://svc-rag-retriever:8000"
# Create app and settings
app, settings = create_app(
service_name="svc-coverage",
title="Tax Agent Coverage Policy Service",
description="Coverage policy evaluation and clarification service",
settings_class=CoverageSettings,
)
# Global state
neo4j_client: Neo4jClient | None = None
event_bus: EventBus | None = None
policy_loader: PolicyLoader | None = None
current_policy: Any = None
@app.on_event("startup")
async def startup_event() -> None:
"""Initialize service dependencies"""
global neo4j_client, event_bus, policy_loader, current_policy
# Setup observability
setup_observability(settings)
# Initialize Neo4j client
neo4j_driver = create_neo4j_client(settings)
neo4j_client = Neo4jClient(neo4j_driver)
# Initialize event bus
event_bus = create_event_bus(settings)
# Initialize policy loader
policy_loader = get_policy_loader(settings.config_dir)
# Load initial policy
try:
policy = policy_loader.load_policy()
current_policy = policy_loader.compile_predicates(policy)
logger.info("Initial policy loaded", version=policy.version)
except Exception as e:
logger.error("Failed to load initial policy", error=str(e))
current_policy = None
logger.info("Coverage service started")
@app.on_event("shutdown")
async def shutdown_event() -> None:
"""Cleanup service dependencies"""
global neo4j_client, event_bus
if neo4j_client:
await neo4j_client.close()
if event_bus:
await event_bus.close()
logger.info("Coverage service stopped")
# Request/Response models
class CheckCoverageRequest(BaseModel):
"""Request to check document coverage"""
tax_year: str
taxpayer_id: str
class ClarifyRequest(BaseModel):
"""Request to generate clarifying question"""
gap: CoverageGap
context: ClarifyContext
class ReloadRequest(BaseModel):
"""Request to reload policy"""
force: bool = False
# Metrics
metrics = get_metrics()
tracer = get_tracer()
@app.post("/v1/coverage/check")
async def check_coverage(
request: CheckCoverageRequest,
current_user: dict[str, Any] = Depends(get_current_user),
tenant_id: str = Depends(get_tenant_id),
) -> CoverageReport:
"""Check document coverage for taxpayer"""
with tracer.start_as_current_span("check_coverage") as span:
span.set_attribute("taxpayer_id", request.taxpayer_id)
span.set_attribute("tax_year", request.tax_year)
span.set_attribute("tenant_id", tenant_id)
try:
if not current_policy:
raise HTTPException(status_code=503, detail="Policy not loaded")
# Create evaluator with KG and RAG clients
evaluator = CoverageEvaluator(
kg_client=neo4j_client,
rag_client=None, # TODO: Initialize RAG client
)
# Perform coverage evaluation
report = await evaluator.check_document_coverage(
request.taxpayer_id,
request.tax_year,
current_policy,
)
# Record audit trail
await _record_coverage_audit(report, tenant_id)
# Update metrics
metrics.counter("coverage_checks_total").labels(
tenant_id=tenant_id,
tax_year=request.tax_year,
overall_status=report.overall_status.value,
).inc()
return report
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
logger.error(
"Coverage check failed",
taxpayer_id=request.taxpayer_id,
tax_year=request.tax_year,
error=str(e),
)
raise HTTPException(
status_code=500, detail=f"Coverage check failed: {str(e)}"
) from e
@app.post("/v1/coverage/clarify")
async def clarify_gap(
request: ClarifyRequest,
current_user: dict[str, Any] = Depends(get_current_user),
tenant_id: str = Depends(get_tenant_id),
) -> ClarifyResponse:
"""Generate clarifying question for coverage gap"""
with tracer.start_as_current_span("clarify_gap") as span:
span.set_attribute("schedule_id", request.gap.schedule_id)
span.set_attribute("evidence_id", request.gap.evidence_id)
span.set_attribute("tenant_id", tenant_id)
try:
if not current_policy:
raise HTTPException(status_code=503, detail="Policy not loaded")
# Generate clarifying question
response = await _generate_clarifying_question(request.gap, request.context)
# Update metrics
metrics.counter("clarifications_total").labels(
tenant_id=tenant_id,
schedule_id=request.gap.schedule_id,
evidence_id=request.gap.evidence_id,
).inc()
return response
except HTTPException:
# Re-raise HTTP exceptions as-is
raise
except Exception as e:
logger.error(
"Clarification failed",
gap=request.gap.dict(),
error=str(e),
)
raise HTTPException(
status_code=500, detail=f"Clarification failed: {str(e)}"
) from e
@app.post("/admin/coverage/reload")
async def reload_policy(
request: ReloadRequest,
current_user: dict[str, Any] = Depends(get_current_user),
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""Reload coverage policy from files"""
# Check admin permissions
user_groups = current_user.get("groups", [])
if "admin" not in user_groups:
raise HTTPException(status_code=403, detail="Admin access required")
with tracer.start_as_current_span("reload_policy") as span:
span.set_attribute("tenant_id", tenant_id)
span.set_attribute("force", request.force)
try:
global current_policy
if not policy_loader:
raise HTTPException(
status_code=503, detail="Policy loader not initialized"
)
# Load and compile new policy
policy = policy_loader.load_policy()
new_compiled_policy = policy_loader.compile_predicates(policy)
# Record new policy version
await _record_policy_version(new_compiled_policy, tenant_id)
# Update current policy
current_policy = new_compiled_policy
logger.info(
"Policy reloaded",
version=policy.version,
hash=new_compiled_policy.hash,
tenant_id=tenant_id,
)
return {
"success": True,
"version": policy.version,
"hash": new_compiled_policy.hash,
"compiled_at": new_compiled_policy.compiled_at.isoformat(),
"source_files": new_compiled_policy.source_files,
}
except PolicyError as e:
logger.error("Policy reload failed", error=str(e))
raise HTTPException(
status_code=400, detail=f"Policy error: {str(e)}"
) from e
except Exception as e:
logger.error("Policy reload failed", error=str(e))
raise HTTPException(
status_code=500, detail=f"Reload failed: {str(e)}"
) from e
@app.get("/v1/coverage/policy")
async def get_current_policy(
current_user: dict[str, Any] = Depends(get_current_user),
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""Get current compiled policy (no secrets, no PII)"""
with tracer.start_as_current_span("get_policy") as span:
span.set_attribute("tenant_id", tenant_id)
if not current_policy:
raise HTTPException(status_code=503, detail="Policy not loaded")
# Return sanitized policy info
return {
"version": current_policy.policy.version,
"jurisdiction": current_policy.policy.jurisdiction,
"tax_year": current_policy.policy.tax_year,
"compiled_at": current_policy.compiled_at.isoformat(),
"hash": current_policy.hash,
"source_files": current_policy.source_files,
"schedules": list(current_policy.policy.schedules.keys()),
"document_kinds": current_policy.policy.document_kinds,
}
@app.get("/v1/coverage/validate")
async def validate_policy(
current_user: dict[str, Any] = Depends(get_current_user),
tenant_id: str = Depends(get_tenant_id),
) -> ValidationResult:
"""Validate current policy configuration"""
with tracer.start_as_current_span("validate_policy") as span:
span.set_attribute("tenant_id", tenant_id)
try:
if not policy_loader:
raise HTTPException(
status_code=503, detail="Policy loader not initialized"
)
# Load policy as dict for validation
policy_dict = policy_loader._load_yaml_file(
os.path.join(settings.config_dir, "coverage.yaml")
)
# Validate policy
result = policy_loader.validate_policy(policy_dict)
# Additional validation: check box existence in KG
if neo4j_client and result.ok:
box_validation_errors = await _validate_boxes_in_kg(policy_dict)
if box_validation_errors:
result.errors.extend(box_validation_errors)
result.ok = False
return result
except Exception as e:
logger.error("Policy validation failed", error=str(e))
return ValidationResult(
ok=False,
errors=[f"Validation failed: {str(e)}"],
)
# Helper functions
async def _record_coverage_audit(report: CoverageReport, tenant_id: str) -> None:
"""Record coverage audit trail"""
# TODO: Implement database recording
logger.info(
"Coverage audit recorded",
taxpayer_id=report.taxpayer_id,
tax_year=report.tax_year,
overall_status=report.overall_status.value,
blocking_items=len(report.blocking_items),
tenant_id=tenant_id,
)
async def _record_policy_version(compiled_policy: Any, tenant_id: str) -> None:
"""Record new policy version"""
# TODO: Implement database recording
logger.info(
"Policy version recorded",
version=compiled_policy.policy.version,
hash=compiled_policy.hash,
tenant_id=tenant_id,
)
async def _generate_clarifying_question(
gap: CoverageGap, context: ClarifyContext
) -> ClarifyResponse:
"""Generate clarifying question for coverage gap"""
if not current_policy:
raise ValueError("Policy not loaded")
# Get question template
templates = current_policy.policy.question_templates
default_template = templates.default
# Build question text
evidence_name = gap.evidence_id
schedule_name = gap.schedule_id
boxes_text = ", ".join(gap.boxes) if gap.boxes else "relevant boxes"
alternatives_text = (
", ".join(gap.acceptable_alternatives)
if gap.acceptable_alternatives
else "alternative documents"
)
question_text = default_template["text"].format(
schedule=schedule_name,
tax_year=context.tax_year,
evidence=evidence_name,
boxes=boxes_text,
alternatives=alternatives_text,
)
why_text = default_template["why"].format(
why=gap.reason,
guidance_doc="policy guidance",
)
# Build upload options
options = []
if gap.acceptable_alternatives:
for alt in gap.acceptable_alternatives:
options.append(
UploadOption(
label=f"Upload {alt} (PDF/CSV)",
accepted_formats=["pdf", "csv"],
upload_endpoint=f"/v1/ingest/upload?tag={alt}",
)
)
else:
options.append(
UploadOption(
label=f"Upload {evidence_name} (PDF/CSV)",
accepted_formats=["pdf", "csv"],
upload_endpoint=f"/v1/ingest/upload?tag={evidence_name}",
)
)
return ClarifyResponse(
question_text=question_text,
why_it_is_needed=why_text,
citations=gap.citations,
options_to_provide=options,
blocking=(gap.role.value == "REQUIRED"),
boxes_affected=gap.boxes,
)
async def _validate_boxes_in_kg(policy_dict: dict[str, Any]) -> list[str]:
"""Validate that all referenced boxes exist in KG"""
if not neo4j_client:
return ["KG client not available for box validation"]
errors = []
all_boxes = set()
# Collect all box references
for schedule in policy_dict.get("schedules", {}).values():
for evidence in schedule.get("evidence", []):
all_boxes.update(evidence.get("boxes", []))
if all_boxes:
try:
from libs.neo import kg_boxes_exist
box_existence = await kg_boxes_exist(neo4j_client, list(all_boxes))
for box_id, exists in box_existence.items():
if not exists:
errors.append(f"Form box '{box_id}' not found in knowledge graph")
except Exception as e:
errors.append(f"Failed to validate boxes in KG: {str(e)}")
return errors
# Health check endpoints
@app.get("/healthz")
async def health_check() -> dict[str, str]:
"""Health check endpoint"""
return {"status": "healthy", "service": "svc-coverage"}
@app.get("/readyz")
async def readiness_check() -> dict[str, str]:
"""Readiness check endpoint"""
return {"status": "ready", "service": "svc-coverage"}
@app.get("/livez")
async def liveness_check() -> dict[str, str]:
"""Liveness check endpoint"""
return {"status": "alive", "service": "svc-coverage"}
# Metrics endpoint (internal only)
@app.get("/metrics")
async def get_metrics_endpoint() -> str:
"""Prometheus metrics endpoint"""
# This would return Prometheus format metrics
return "# Coverage service metrics\n"
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,46 @@
"""Database models for coverage service."""
# FILE: apps/svc-coverage/models.py
from datetime import datetime
from sqlalchemy import JSON, Column, DateTime, Integer, String
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class CoverageVersion(Base):
"""Policy version tracking table"""
__tablename__ = "coverage_versions"
id = Column(Integer, primary_key=True, autoincrement=True)
version = Column(String(50), nullable=False)
jurisdiction = Column(String(10), nullable=False)
tax_year = Column(String(10), nullable=False)
tenant_id = Column(String(100), nullable=True)
source_files = Column(JSON, nullable=False, default=list)
compiled_at = Column(DateTime, nullable=False, default=datetime.utcnow)
hash = Column(String(64), nullable=False)
def __repr__(self) -> str:
return f"<CoverageVersion(id={self.id}, version='{self.version}', hash='{self.hash[:8]}...')>"
class CoverageAudit(Base):
"""Coverage evaluation audit trail"""
__tablename__ = "coverage_audit"
id = Column(Integer, primary_key=True, autoincrement=True)
taxpayer_id = Column(String(100), nullable=False)
tax_year = Column(String(10), nullable=False)
policy_version = Column(String(50), nullable=False)
overall_status = Column(String(20), nullable=False)
blocking_items = Column(JSON, nullable=False, default=list)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
trace_id = Column(String(100), nullable=True)
def __repr__(self) -> str:
return f"<CoverageAudit(id={self.id}, taxpayer_id='{self.taxpayer_id}', status='{self.overall_status}')>"