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
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:
53
apps/svc_coverage/Dockerfile
Normal file
53
apps/svc_coverage/Dockerfile
Normal 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"]
|
||||
1
apps/svc_coverage/__init__.py
Normal file
1
apps/svc_coverage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Coverage service package."""
|
||||
112
apps/svc_coverage/alembic.ini
Normal file
112
apps/svc_coverage/alembic.ini
Normal 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
|
||||
92
apps/svc_coverage/alembic/env.py
Normal file
92
apps/svc_coverage/alembic/env.py
Normal 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()
|
||||
24
apps/svc_coverage/alembic/script.py.mako
Normal file
24
apps/svc_coverage/alembic/script.py.mako
Normal 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"}
|
||||
@@ -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
523
apps/svc_coverage/main.py
Normal 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)
|
||||
46
apps/svc_coverage/models.py
Normal file
46
apps/svc_coverage/models.py
Normal 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}')>"
|
||||
Reference in New Issue
Block a user