Commit graph

217 commits

Author SHA1 Message Date
Daniel Roth
da789392ab Complete the task for a void property instead of dead-lettering it 🟩
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:26:42 +00:00
Daniel Roth
ab7b5e388f
Merge pull request #1683 from Hestia-Homes/feature/abri-api-tweaks
Key Abri log/amend/abandon off client_booking_reference, not the survey-date edge
2026-07-27 12:10:22 +01:00
Daniel Roth
e85a0cb14f Fail non-retriably when amending or abandoning a deal that never logged to Abri 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:54:15 +00:00
Daniel Roth
fb64f9687e Fail non-retriably when amending or abandoning a deal that never logged to Abri 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:53:50 +00:00
Daniel Roth
f477e6dc7d Carry the client_booking_reference on the Abri trigger message 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:37:39 +00:00
KhalimCK
d5b94996df
Merge pull request #1677 from Hestia-Homes/feat/roof-sloping-ceiling
Pitched sloping-ceiling roof-type family (closes #1676)
2026-07-24 14:32:42 +01:00
KhalimCK
206ac197ee
Merge pull request #1544 from Hestia-Homes/feat/calico-stock-condition-ingestion
Calico roof-covering stock-condition ingestion (ADR-0064)
2026-07-24 14:31:54 +01:00
Khalim Conn-Kowlessar
6f8724aa04 Route sloping-ceiling roof descriptions deterministically off the loft ladder 🟩
Compose the party-ceiling and sloping-ceiling guards in the roof
GuardedColumnClassifier so a PitchedWithSlopingCeiling override resolves to the
Pitched, sloping ceiling family instead of being routed onto Pitched, N mm loft
insulation by the LLM (ADR-0066).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:53:56 +00:00
KhalimCK
49a36581f0
Merge pull request #1546 from Hestia-Homes/feat/ara-scenario-export
ARA Scenario Export: branded sheet-per-scenario XLSX export (ADR-0065)
2026-07-23 09:34:51 +01:00
Daniel Roth
ae1da26558 Move the PasHub fetcher into the DDD layer structure 🟪
The service was the last one living wholly under backend/. It now follows
the same layering as abri and the other newer services:

  domain/pashub_fetcher/          core file classification, subfolders
  infrastructure/pashub_fetcher/  PasHub client, token getter, wire DTOs
  orchestration/                  PashubFetcherOrchestrator (was PashubService)
  applications/pashub_fetcher/    lambda handler, trigger request, dev tooling

core_files.py is split along the layer boundary: the domain module keeps the
filename/evidence-category classification rules and no longer imports
infrastructure.postgres, while the CoreFiles -> FileTypeEnum translation moves
to infrastructure/pashub_fetcher/core_file_types.py.

Tests move into the tests/ tree by layer. Note this puts them in the only
suite CI currently runs (unit_tests.yml is disabled), so these 73 tests now
execute on PRs for the first time; they were previously reachable only via
the legacy pytest.ini testpaths.

sharepoint_renamer's image now copies just domain/pashub_fetcher/ rather than
the whole service, since SharepointSubfolders is all it needed.

Behaviour is unchanged. tests/ goes 9927 -> 10000 passed (+73, exactly the
tests that moved in); the legacy suite keeps its same 17 pre-existing failures
and 11 errors. tests/test_lambda_packaging.py confirms both changed
Dockerfiles still copy their handler's full import closure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:35:54 +00:00
Khalim Conn-Kowlessar
72f3e3a72d Add default-plan export selection + plan_name column (ADR-0065)
A second export selection alongside the per-Scenario one, chosen by
`plan_selection` on the export request:

- "scenario" (default, unchanged): one sheet per Scenario, freshest Plan per
  (property, scenario); scenario_ids required.
- "default" (new): a single "Default Plans" sheet of each home's is_default
  Plan (one-per-property across all Scenarios, ADR-0012/0017) — the portfolio's
  current state, one row per home; scenario_ids ignored.

Repository gains `default_rows_for(portfolio_id, property_ids)`: same read-model
and Effective-EPC join as `rows_for`, with the plan-selection WHERE swapped from
`scenario_id = :scenario_id` to `is_default = TRUE`. Orchestrator branches on
plan_selection; the router validates it and only requires scenario_ids for the
scenario selection. A recipe without plan_selection defaults to "scenario", so
the change is backward-compatible.

Both selections now surface the chosen Plan's `name` as a `plan_name` column.

Tests: repository default_rows_for (scenario-independent selection, plan_name,
non-default excluded); orchestrator default path (one "Default Plans" sheet;
empty selection is a recorded failure). 62 passed, pyright --strict clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:21:38 +00:00
Khalim Conn-Kowlessar
f1e190293b Address review: stream workbook to /tmp, repo the scenario-name SQL
Dan's review on #1546:

- Stream the workbook to a /tmp temp file and S3Client.upload_file it, instead
  of render_workbook -> bytes -> put_object. render_workbook now saves straight
  to a path; the orchestrator renders to a temp file, multipart-uploads it, and
  always cleans it up. Restores ADR-0065's "never an in-memory BytesIO" decision
  (the OOM path at the 100k-row cap).

- Move the raw `SELECT ... FROM scenario` out of the Lambda handler into
  ScenarioNamesPostgresRepository, so the handler stays composition-only and all
  SQL lives in repositories/.

- current_sap_points goes through _float, matching its Optional[float] read-model
  type and the sibling numeric facts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:51:51 +00:00
Daniel Roth
18dfda038d tweaks 2026-07-17 13:21:47 +00:00
Daniel Roth
edd4ec00de Make the OpenHousing resource mandatory, dropping the default fallback
The deal's third_party_surveyor_identifier is now required by the log
and amend flows: the trigger contract rejects messages without it
(blank treated as missing, since a blank resource makes OpenHousing
silently drop the appointment), and LogJobRequest/AmendJobRequest carry
it as a required field. The ABRI_RELAY_DEFAULT_RESOURCE fallback and
its config/terraform/workflow wiring are removed as dead code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:13:28 +00:00
Daniel Roth
c8fe4585bf Send the deal's third-party surveyor as the OpenHousing resource
Carry third_party_surveyor_identifier on the Abri trigger message and
through dispatch into the log and amend flows, booking the job against
that resource. LogJobRequest gains the optional resource field
AmendJobRequest already had; the client falls back to the configured
default surveyor when the deal names no third-party resource, so
existing deals behave unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:00:47 +00:00
Daniel Roth
dcf5628029 Model the newer stored survey when refetching EPCs 🟪
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:40:52 +00:00
Daniel Roth
870ec68dba Model the newer stored survey when refetching EPCs 🟩
Read stored lodged EPCs whatever refetch_epc says, so a stored assessment
can win the ADR-0001 Recency Tie-Break against the gov register.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:40:15 +00:00
Daniel Roth
aeae322a4e Use the fetched gov cert when nothing is stored 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:35:19 +00:00
Daniel Roth
dd1ea99c3d Use the fetched gov cert when it is the newer assessment 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:34:37 +00:00
Daniel Roth
ec584fc84c Prefer the newer of a stored survey and a fetched gov cert 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:33:06 +00:00
Daniel Roth
d1be7c2842 Prefer the newer of a stored survey and a fetched gov cert 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:32:50 +00:00
Jun-te Kim
3dd86f296d
Merge pull request #1549 from Hestia-Homes/feature/expired-predicted-source
feat(modelling_e2e): condition prediction on the expired Historic EPC (ADR-0054)
2026-07-14 13:14:05 +01:00
Jun-te Kim
1457275fb5 fix(modelling_e2e): ship the Historic EPC import closure in the Lambda image
test_lambda_image_copies_full_import_closure caught this: importing the Historic
EPC S3 repo drags datatypes/epc/domain/historic_epc_matching.py into the handler's
init-time closure, and that reaches back into the legacy address matcher —
backend/address2UPRN/scoring.py and utils/pandas_utils.py. The image COPYed
neither, so the Lambda would have died at cold start with Runtime.ImportModuleError.

Copied file-by-file rather than `COPY backend/ backend/`: backend/ is the whole
legacy engine and the closure needs only these seven files. Their third-party
deps (pandas, requests) are already in requirements.txt, so no new pip installs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:35:02 +00:00
Jun-te Kim
efdb8e816c feat(modelling_e2e): condition prediction on the expired Historic EPC (ADR-0054)
ADR-0054 gives a prediction conditioned by an expired Historic EPC the source
"expired", so reporting can tell "no EPC at all" apart from "only an expired
one". It was implemented in IngestionOrchestrator — but the pipeline that
actually runs is modelling_e2e, which predicts through its own _predict_epc and
never adopted it. Result: zero `expired` rows have ever been written, and
`epc_property.source` holds only 'lodged' and 'predicted'.

Two things kept the flavour unreachable here, and both had to go:

  - _predict_epc never looked at the historic backup at all, so no prediction
    was ever conditioned.
  - _flush_writes passed the literal source="predicted", and _PropertyWrite had
    no field to carry a flavour, so one would have been dropped before the write
    even if computed.

_predict_epc now mirrors IngestionOrchestrator._predict: the expired cert's
stable attributes fill the gaps Landlord Overrides left (overrides still win
where both speak) and condition the cohort, and it returns the source alongside
the EPC. _PropertyWrite carries it; _flush_writes persists it. save_batch
already groups deletes by source family, so a mixed predicted/expired batch
clears the shared slot correctly.

Ships dark: the reader is built only when HISTORIC_EPC_S3_ROOT is set, so
without it every prediction stays plain "predicted", exactly as today.
DEFAULT_S3_ROOT names the dev bucket, so defaulting to it would have a prod
lambda silently reading dev data — Terraform sets the var per environment.

Note this also rescues properties that currently fail to model outright: an
expired cert can supply the property_type no override resolved, where today
that raises UnresolvedPropertyTypeError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:15:39 +00:00
Daniel Roth
f7328f54c5 Fetch a property's lodged EPC by UPRN, not property_id
An epc_property row can carry a null property_id (FE/historic ingestion
persists EPC rows never linked to a property row), so a property_id-keyed
read silently misses them. UPRN is the durable key both the property row
and the epc_property row share. Repoint the lodged reads (get_for_property
+ get_for_properties) onto UPRN; the predicted reads stay on property_id
because a predicted EPC deep-copies a neighbour's UPRN, so its UPRN column
is never the property's own.

UPRN is not unique on epc_property, so the read tie-break is now load-
bearing (it was dormant under property_id, where the write path guarantees
one lodged row per property): the most recently ingested (highest-id) row
wins. Write path stays keyed on (property_id, source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:35:51 +00:00
Khalim Conn-Kowlessar
8bd9c564dc Add the scenario export route, worker handler and SQS trigger 🟩
Wires POST /v1/exports/scenario -> tasks.inputs recipe -> pinned sub_task ->
ARA_EXPORT_SQS_URL -> ara_export Lambda -> orchestrator. Route resolution and the
trigger body are covered by tests; the Lambda handler mirrors bulk_document_download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:36:14 +00:00
Khalim Conn-Kowlessar
689b0a5618 Fix the condition Lambda Dockerfile for the DDD layout
The lift-and-shift moved the handler to applications/condition/handler.py and
split the module across domain/infrastructure/repositories/applications, but the
Dockerfile still COPYed backend/condition/ and ran the old CMD path. Update the
COPYs to the handler's real import closure (the DDD dirs + the legacy backend
Base/db_session/settings bridges) and the CMD to applications/condition/handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:03:49 +00:00
Khalim Conn-Kowlessar
37745939b3 Resolve Calico UPRNs from the address-matched S3 CSV lookup
Calico Asset References don't match our landlord_property_id, so the runner now
resolves UPRNs from the address-matched CSV (UprnLookupS3) uploaded to S3, not
the property-table PropertyUprnLookup. run_calico_load accepts any UprnLookup
(null_uprn count applies only to the property-table lookup). Adds a dry_run
option to reconcile without writing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:22:53 +00:00
Khalim Conn-Kowlessar
f1cf7babad Record the confirmed Calico UPRN-join mismatch and S3 source
Confirmed against the DB: Calico Asset References have zero overlap with
portfolio 824's landlord_property_id (addresses match; all 824 UPRNs non-null),
so the property-table lookup cannot resolve Calico. Documents the open
resolution (Calico-supplied ref->UPRN table or address matching) in ADR-0064,
the runner, and the README, and pins the confirmed S3 source location.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:34:23 +00:00
Khalim Conn-Kowlessar
0588ae233a Document null_uprn as a portfolio-wide canary, not a partitioning drop count
Records the agreed reconciliation semantics: null_uprn reports portfolio
properties missing a UPRN (expected zero) and may overlap unmatched_to_portfolio,
rather than strictly partitioning the Calico rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:23:46 +00:00
Khalim Conn-Kowlessar
3a77a1a811 Wire the one-off Calico S3 load runner
run_calico_s3_load reads the Calico export from S3 and runs it through
run_calico_load against portfolio 824, resolving references to UPRNs via the
property table and persisting with ConditionPostgres. S3 + Postgres wiring
around the unit-tested load; run where a database and S3 are available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:15:38 +00:00
Khalim Conn-Kowlessar
773869ada4 Run a Calico load end to end and return its reconciliation 🟩
run_calico_load ties parse -> map -> persist together: it resolves references to
UPRNs, drops blank/unmatched rows, persists the roof-covering surveys with
export-year-anchored renewal years, and returns the four-count LoadReport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:13:33 +00:00
Khalim Conn-Kowlessar
a6a48dd337 Run a Calico load end to end and return its reconciliation 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:13:06 +00:00
Khalim Conn-Kowlessar
188176d81e Select the Calico parser and mapper by file type 🟩
Registers ConditionFileType.Calico in the parser/mapper factory; the Calico
parser requires a UPRN lookup (like Peabody) since references resolve to UPRNs
via the property table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:11:56 +00:00
Khalim Conn-Kowlessar
a6b782d5b3 Select the Calico parser and mapper by file type 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:11:17 +00:00
Khalim Conn-Kowlessar
75c561e9db Summarise a Calico load as a four-count reconciliation report 🟩
LoadReport.from_calico_load reports loaded observations alongside the three drop
reasons (unmatched-to-portfolio, null-uprn, blank-placeholder) so every Calico
row is accounted for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:10:08 +00:00
Khalim Conn-Kowlessar
87eecc6400 Summarise a Calico load as a four-count reconciliation report 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:09:39 +00:00
Khalim Conn-Kowlessar
735e83cef2 Relocate condition-data ingestion into the DDD layout 🟪
Behaviour-preserving lift-and-shift of the condition module out of legacy
backend/condition/ into domain/condition, infrastructure/condition,
infrastructure/postgres/condition_tables.py, repositories/condition,
applications/condition, and tests/condition. Imports rewritten to the DDD
paths; ConditionPostgres and the ORM models keep the legacy backend.app.db
Base/db_session bridges so the existing suite proves behaviour is unchanged
(SQLModel + session-DI conversion tracked as a follow-up in ADR-0064).

16 condition tests pass at the new location.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:51:25 +00:00
Jun-te Kim
bd3ff8fcdc
Merge pull request #1513 from Hestia-Homes/fix/widen-cohort-search-v2
Widen EPC-prediction cohort search one step when the normal radius is too sparse
2026-07-08 16:17:57 +01:00
Khalim Conn-Kowlessar
91db972d48 Bulk download: readable email, deal-name folders, visible worker logs
First successful live run surfaced three issues:

1. Email looked rubbish (a giant raw presigned URL). Now sends a proper HTML
   email with a 'Download documents' button plus a plain-text fallback, and a
   summary (N documents across M properties, expiry). Email delivery is now
   best-effort: a transport failure no longer loses an already-built package
   (the link is still on sub_task.outputs), and the SMTP connect has a 30s
   timeout so an unreachable SES endpoint fails fast instead of hanging to the
   900s Lambda timeout.

2. Every folder was 'address unavailable (...)': the resolver read property.address,
   but these are HubSpot deals with no property row. It now uses the deal's
   dealname from hubspot_deal_data.

3. No logs / no idea why a run took ~9 minutes: the worker's INFO logs were
   dropped (Lambda root logger defaults to WARNING). The handler now raises the
   level, and the orchestrator logs per-phase timing and volume (gather+plan,
   packaged N files / X MB, upload, email, total) so the slow phase is visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:02:38 +00:00
Jun-te Kim
c9e0180c12 Widen EPC-prediction cohort search one step when the normal radius is too sparse
ADR-0034's nearby-postcode broadening degrades to a genuine
NoSameTypeComparablesError whenever the default 1000m/30-postcode reach has no
same-type comparable nearby (e.g. property_id=752685, portfolio 824 — the only
Maisonette within reach). Adds one configurable extra widening step
(EpcComparablePropertiesRepository.candidates_near now accepts
widen_nearby_postcodes), tried only when the normal-radius walk falls short of
`minimum` matches. modelling_e2e's handler wires this to a 3000m/60-postcode
PostcodesIoClient as the single wider step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 14:29:21 +00:00
KhalimCK
bd24a789b9
Merge pull request #1498 from Hestia-Homes/feature/bulk-document-download
Bulk Document Download: emailed ZIP of a property set's documents (ADR-0059/0060)
2026-07-08 12:49:46 +01:00
Khalim Conn-Kowlessar
9ec7987e97 PR review: best-effort read path, true per-member streaming, race-safe 409, local email 🟩
Addresses PR #1498 review:
- A per-file read failure (missing/deleted object, or a bucket the role can't
  reach) is now a SkippedDocument(reason=unreadable), not a whole-run abort;
  an all-unreadable selection still fails (no empty package). Best-effort on
  the read path (ADR-0060).
- Each ZIP member is streamed to a temp file (S3DocumentDownloader.download,
  boto download_file) and added from disk, so a single multi-GB member never
  hits the heap — the 'never held whole in memory' claim is now true.
- The 409 double-submit guard is DB-arbitrated: the sub_task insert is
  conditional on the task having none, so a race creates only one.
- Local env gets a placeholder recipient email so the route is exercisable.
- PackageEntry carries landlord_property_id so a read failure can be reported.
- TODO noting the UploadedFile.s3_upload_timestamp typing fix.
Two new tests: per-file read failure skips-and-reports; address fallback flows
to the folder name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:08:46 +00:00
Daniel Roth
8b7d32433e
Merge pull request #1492 from Hestia-Homes/feature/abri-api-integration
Abri API integration: abandon job
2026-07-08 10:53:21 +01:00
Khalim Conn-Kowlessar
8c0b015923 bulk_document_download Lambda: attach-mode handler, trigger body, packaging 🟩
Reads the recipe from sub_task.inputs, assembles the Download Package via the
orchestrator (source docs from each row's bucket -> ZIP -> the dedicated
DOCUMENT_EXPORTS_BUCKET), returns the presigned URL + skip summary for
sub_task.outputs. Dockerfile/requirements mirror bulk_upload_finaliser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:51:51 +00:00
Daniel Roth
84d0e1e0d5 Date an abandonment to the deal's own dates, not now() 🟩
date_abandoned now resolves to the third failed attempt's confirmed
survey date, falling back to its last submission date, rather than the
processing date. This dates the OpenHousing cancellation to when the
job actually lapsed even if the trigger message lags or redelivers.

- domain: abandonment_date() encodes the confirmed-survey -> last-
  submission fallback
- DealAbandonment carries both dates; abandon_job raises
  AbandonmentDateUnknownError when a deal has neither
- last_submission_date now flows through the trigger message and request
- the injected clock is dropped: nothing reads now() any more

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 08:51:39 +00:00
Khalim Conn-Kowlessar
8dd125f199 Review nitpicks: pids_with_default naming, empty-batch guard, truthful fake 🟪
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:44:23 +00:00
Khalim Conn-Kowlessar
0eaf6fa99c A property's first plan becomes its default whatever the scenario says 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:13:47 +00:00
Daniel Roth
8aa2841546 Dispatch the abandon flow last for a fired deal-change 🟩
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:46:00 +00:00
Daniel Roth
db5ff971d8 Accept an abandon_job flow carrying the deal outcome 🟩
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:42:34 +00:00