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
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user