completed local setup with compose
Some checks failed
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 / 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 / Notifications (push) Has been cancelled

This commit is contained in:
harkon
2025-11-26 13:17:17 +00:00
parent 8fe5e62fee
commit fdba81809f
87 changed files with 5610 additions and 3376 deletions

View File

@@ -43,7 +43,7 @@ RUN chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8000/healthz || exit 1
# Expose port

View File

@@ -17,6 +17,7 @@ from datetime import datetime
from decimal import Decimal
from typing import Any
import httpx
import structlog
import ulid
from fastapi import BackgroundTasks, Depends, HTTPException, Request
@@ -55,6 +56,9 @@ class ReasonSettings(BaseAppSettings):
max_income: float = 10000000.0 # £10M
max_expenses: float = 10000000.0 # £10M
# External services
coverage_service_url: str = "http://svc-coverage:8000"
# Create app and settings
app, settings = create_app(
@@ -67,6 +71,7 @@ app, settings = create_app(
# Global clients
neo4j_client: Neo4jClient | None = None
event_bus: EventBus | None = None
http_client: httpx.AsyncClient | None = None
tracer = get_tracer("svc-reason")
metrics = get_metrics()
@@ -74,7 +79,7 @@ metrics = get_metrics()
@app.on_event("startup")
async def startup_event() -> None:
"""Initialize service dependencies"""
global neo4j_client, event_bus
global neo4j_client, event_bus, http_client
logger.info("Starting reasoning service")
@@ -89,6 +94,9 @@ async def startup_event() -> None:
event_bus = create_event_bus(settings)
await event_bus.start() # fmt: skip# pyright: ignore[reportOptionalMemberAccess]
# Initialize HTTP client
http_client = httpx.AsyncClient()
# Subscribe to KG upsert events
await event_bus.subscribe(EventTopics.KG_UPSERTED, _handle_kg_upserted) # type: ignore
@@ -98,7 +106,7 @@ async def startup_event() -> None:
@app.on_event("shutdown")
async def shutdown_event() -> None:
"""Cleanup service dependencies"""
global neo4j_client, event_bus
global neo4j_client, event_bus, http_client
logger.info("Shutting down reasoning service")
@@ -108,6 +116,9 @@ async def shutdown_event() -> None:
if event_bus:
await event_bus.stop()
if http_client:
await http_client.aclose()
logger.info("Reasoning service shutdown complete")
@@ -259,41 +270,76 @@ async def get_calculation_results(
async def _handle_kg_upserted(topic: str, payload: EventPayload) -> None:
"""Handle KG upsert events for auto-calculation"""
"""Handle KG upsert events for auto-calculation and coverage check"""
data = payload.data
taxpayer_id = data.get("taxpayer_id")
tax_year = data.get("tax_year")
tenant_id = data.get("tenant_id")
if not taxpayer_id or not tax_year or not tenant_id:
logger.warning("Invalid KG upsert event data for coverage check", data=data)
return
# Trigger svc-coverage check
try:
data = payload.data
entities = data.get("entities", [])
tenant_id = data.get("tenant_id")
# Check if we have enough data for calculation
has_income = any(e.get("type") == "IncomeItem" for e in entities)
has_expenses = any(e.get("type") == "ExpenseItem" for e in entities)
if has_income or has_expenses:
if http_client:
coverage_url = f"{settings.coverage_service_url}/v1/coverage/check"
request_body = {
"tax_year": tax_year,
"taxpayer_id": taxpayer_id,
}
headers = {
"X-Tenant-ID": tenant_id,
# Assuming current_user is not directly available here,
# or a system user token needs to be generated.
# For now, omitting X-Authenticated-User for simplicity,
# but in a real system, this should be handled securely.
}
response = await http_client.post(coverage_url, json=request_body, headers=headers)
response.raise_for_status()
coverage_report = response.json()
logger.info(
"Auto-triggering calculation due to new financial data",
tenant_id=tenant_id,
"Triggered svc-coverage check",
taxpayer_id=taxpayer_id,
tax_year=tax_year,
coverage_status=coverage_report.get("overall_status"),
)
# Find taxpayer ID from entities
taxpayer_id = None
for entity in entities:
if entity.get("type") == "TaxpayerProfile":
taxpayer_id = entity.get("id")
break
if taxpayer_id:
# If coverage is complete, trigger calculation
if coverage_report.get("overall_status") == "complete":
logger.info(
"Coverage complete, auto-triggering calculation",
taxpayer_id=taxpayer_id,
tax_year=tax_year,
)
await _compute_schedule_async(
tax_year=settings.current_tax_year,
tax_year=tax_year,
taxpayer_id=taxpayer_id,
schedule_id="SA103", # Default to self-employment
tenant_id=tenant_id or "",
tenant_id=tenant_id,
calculation_id=str(ulid.new()),
actor=payload.actor,
)
else:
logger.info(
"Coverage incomplete, not triggering calculation",
taxpayer_id=taxpayer_id,
tax_year=tax_year,
blocking_items=coverage_report.get("blocking_items"),
)
except httpx.HTTPStatusError as e:
logger.error(
"Failed to trigger svc-coverage check due to HTTP error",
taxpayer_id=taxpayer_id,
tax_year=tax_year,
error=str(e),
response_status_code=e.response.status_code,
response_text=e.response.text,
)
except Exception as e:
logger.error("Failed to handle KG upsert for auto-calculation", error=str(e))
logger.error("Failed to handle KG upsert for auto-calculation or coverage check", error=str(e))
async def _compute_schedule_async(
@@ -570,16 +616,107 @@ async def _compute_sa105(
async def _compute_sa100(
financial_data: dict[str, Any], tax_year: str
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Compute SA100 (Main return) schedule"""
# This would aggregate from other schedules
# For now, return basic structure
form_boxes = {
"1": {"value": "John Doe", "description": "Your name", "confidence": 0.9}
}
"""Compute SA100 (Main return) schedule by aggregating other schedules"""
form_boxes = {}
evidence_trail: list[dict[str, Any]] = []
taxpayer_id = financial_data.get("taxpayer_id")
tenant_id = financial_data.get("tenant_id") # Assuming tenant_id is passed in financial_data
if not taxpayer_id or not tenant_id:
raise ValueError("Taxpayer ID or Tenant ID missing for SA100 computation")
# Get latest SA103 calculation
sa103_query = """
MATCH (t:TaxpayerProfile {taxpayer_id: $taxpayer_id, tenant_id: $tenant_id})-[:HAS_CALCULATION]->(c:Calculation)
WHERE c.schedule = 'SA103' AND c.tax_year = $tax_year AND c.retracted_at IS NULL
OPTIONAL MATCH (c)-[:HAS_BOX]->(b:FormBox)
RETURN c.calculation_id AS calculation_id, c.calculated_at AS calculated_at, COLLECT({box: b.box, value: b.value, description: b.description, confidence: b.confidence}) AS form_boxes
ORDER BY c.calculated_at DESC
LIMIT 1
"""
sa103_results = await neo4j_client.run_query( # type: ignore
sa103_query, {"taxpayer_id": taxpayer_id, "tenant_id": tenant_id, "tax_year": tax_year}
)
sa103_calc = sa103_results[0] if sa103_results else None
sa103_net_profit = Decimal("0")
if sa103_calc and sa103_calc["form_boxes"]:
for box in sa103_calc["form_boxes"]:
if box["box"] == "32": # Net profit box in SA103
sa103_net_profit = Decimal(str(box["value"]))
form_boxes["SA103_32"] = {"value": float(sa103_net_profit), "description": "SA103 Net Profit", "confidence": box.get("confidence", 0.9)}
evidence_trail.append({
"box": "SA103_32",
"source_calculation_id": sa103_calc["calculation_id"],
"description": "Derived from SA103 Net Profit"
})
break
# Get latest SA105 calculation
sa105_query = """
MATCH (t:TaxpayerProfile {taxpayer_id: $taxpayer_id, tenant_id: $tenant_id})-[:HAS_CALCULATION]->(c:Calculation)
WHERE c.schedule = 'SA105' AND c.tax_year = $tax_year AND c.retracted_at IS NULL
OPTIONAL MATCH (c)-[:HAS_BOX]->(b:FormBox)
RETURN c.calculation_id AS calculation_id, c.calculated_at AS calculated_at, COLLECT({box: b.box, value: b.value, description: b.description, confidence: b.confidence}) AS form_boxes
ORDER BY c.calculated_at DESC
LIMIT 1
"""
sa105_results = await neo4j_client.run_query( # type: ignore
sa105_query, {"taxpayer_id": taxpayer_id, "tenant_id": tenant_id, "tax_year": tax_year}
)
sa105_calc = sa105_results[0] if sa105_results else None
sa105_net_income = Decimal("0")
if sa105_calc and sa105_calc["form_boxes"]:
for box in sa105_calc["form_boxes"]:
if box["box"] == "net_income": # Net property income box in SA105 (custom box for internal calculation)
sa105_net_income = Decimal(str(box["value"]))
form_boxes["SA105_net_income"] = {"value": float(sa105_net_income), "description": "SA105 Net Property Income", "confidence": box.get("confidence", 0.9)}
evidence_trail.append({
"box": "SA105_net_income",
"source_calculation_id": sa105_calc["calculation_id"],
"description": "Derived from SA105 Net Property Income"
})
break
# Aggregate total income for SA100
total_income = sa103_net_profit + sa105_net_income
form_boxes["SA100_total_income"] = {
"value": float(total_income),
"description": "Total income from all sources",
"confidence": 0.95 # Higher confidence for aggregated value
}
evidence_trail.append({
"box": "SA100_total_income",
"derived_from": ["SA103_32", "SA105_net_income"],
"description": "Aggregated from SA103 net profit and SA105 net property income"
})
# Example: Basic personal allowance (simplified)
personal_allowance = Decimal("12570") # For 2023-24
if total_income > Decimal("100000"): # Tapering not implemented here
personal_allowance = Decimal("0")
form_boxes["SA100_personal_allowance"] = {
"value": float(personal_allowance),
"description": "Personal Allowance",
"confidence": 0.99
}
evidence_trail.append({
"box": "SA100_personal_allowance",
"source": "HMRC_guidance",
"description": f"Standard personal allowance for {tax_year}"
})
# Placeholder for actual SA100 boxes and complex calculations
# This would involve detailed tax band calculations, reliefs, etc.
# For now, we'll just show the aggregation.
form_boxes["1"] = {"value": "John Doe (Aggregated)", "description": "Your name", "confidence": 0.9}
return form_boxes, evidence_trail

View File

@@ -33,3 +33,4 @@ jinja2>=3.1.6
# Statistical calculations
scipy>=1.16.2
httpx