From b57aa83a30b952c550f7d4e75123e8771b8601f3 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 22:38:41 +0200 Subject: [PATCH] feat(fase0): synthetic reference domain (D4) + backend profile skeleton (D2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Fase 0 (skeleton & decision-lock): - reference_domain.py + data/reference_projects.json: a synthetic "anleggskostnad" portfolio (3 fictional construction-cost projects with cost line items) as the framework's bundled reference input. Plain typed loader (frozen dataclasses); the JSON-Schema data-source *contract* (B5) is deliberately deferred to Fase 2. - backends.py: Profile (azure|local) + ChatBackend Protocol seam + AzureFoundryBackend/LocalBackend stubs + get_backend() selector (fail-fast on unknown profile). Empty skeleton per D2 — create_chat_client raises NotImplementedError until live wiring in Fase 1. Return type is the MAF BaseChatClient (the common base of FoundryChatClient/OpenAIChatClient). Quality gate green: ruff format + check, mypy (src) clean, 12 pytest passed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H9FyyENxebxVThjrn9et8C --- src/portfolio_optimiser/backends.py | 84 +++++++++++++++++++ .../data/reference_projects.json | 46 ++++++++++ src/portfolio_optimiser/reference_domain.py | 75 +++++++++++++++++ tests/test_backends.py | 39 +++++++++ tests/test_reference_domain.py | 38 +++++++++ 5 files changed, 282 insertions(+) create mode 100644 src/portfolio_optimiser/backends.py create mode 100644 src/portfolio_optimiser/data/reference_projects.json create mode 100644 src/portfolio_optimiser/reference_domain.py create mode 100644 tests/test_backends.py create mode 100644 tests/test_reference_domain.py diff --git a/src/portfolio_optimiser/backends.py b/src/portfolio_optimiser/backends.py new file mode 100644 index 0000000..312d68c --- /dev/null +++ b/src/portfolio_optimiser/backends.py @@ -0,0 +1,84 @@ +"""Backend profiles (D2): the seam between the framework and a MAF chat client. + +Per research §14, a MAF agent binds to a *chat client* and the model is a +parameter on that client — so model choice is per-client (and later per-agent +via a role->deployment model-map, B12). A "backend profile" selects how models +are served and produces the corresponding MAF chat client. + +This is an EMPTY skeleton for Fase 0: it defines the profile selector and the +seam (`ChatBackend`), with stubs that raise ``NotImplementedError``. Live wiring +lands in Fase 1, when the Group Chat maker-checker spike needs real clients. + +Cost-discipline (D6): the ``LOCAL`` profile (an OpenAI-compatible local endpoint +such as Ollama/LM Studio, driven by the GA ``agent-framework-openai`` client) is +the development default; the ``AZURE`` profile (``FoundryChatClient``) is reserved +for targeted, minimal verification against the private Foundry tenant. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Protocol, runtime_checkable + +from agent_framework import BaseChatClient + + +class Profile(str, Enum): + """Model-serving backend profile (D2).""" + + AZURE = "azure" # Foundry / Azure OpenAI — full profile + LOCAL = "local" # OpenAI-compatible local endpoint — fallback / dev default + + +@runtime_checkable +class ChatBackend(Protocol): + """The seam: a backend produces a MAF chat client for a given model. + + Implementations are Fase 0 skeletons — ``create_chat_client`` raises + ``NotImplementedError`` until wired in Fase 1. ``model`` is the + deployment/model name (resolved from the role->deployment model-map during + onboarding, B12). + """ + + profile: Profile + + def create_chat_client(self, *, model: str) -> BaseChatClient: + """Create a MAF chat client bound to ``model``. Wired in Fase 1.""" + ... + + +class AzureFoundryBackend: + """AZURE profile: ``FoundryChatClient`` against a Foundry project (U18). + + Skeleton — Foundry wiring (``project_endpoint``, ``credential``) lands in Fase 1. + """ + + profile = Profile.AZURE + + def create_chat_client(self, *, model: str) -> BaseChatClient: + raise NotImplementedError( + "AzureFoundryBackend is a Fase 0 skeleton; Foundry wiring lands in Fase 1." + ) + + +class LocalBackend: + """LOCAL profile: ``OpenAIChatClient`` against an OpenAI-compatible local + endpoint (Ollama/LM Studio). Development default per cost-discipline (D6). + + Skeleton — local-endpoint wiring (``base_url``, ``model``) lands in Fase 1. + """ + + profile = Profile.LOCAL + + def create_chat_client(self, *, model: str) -> BaseChatClient: + raise NotImplementedError( + "LocalBackend is a Fase 0 skeleton; local-endpoint wiring lands in Fase 1." + ) + + +def get_backend(profile: Profile | str) -> ChatBackend: + """Select a backend by profile. Fail-fast (``ValueError``) on unknown profile.""" + profile = Profile(profile) # validates: ValueError on unknown string + if profile is Profile.AZURE: + return AzureFoundryBackend() + return LocalBackend() diff --git a/src/portfolio_optimiser/data/reference_projects.json b/src/portfolio_optimiser/data/reference_projects.json new file mode 100644 index 0000000..8e7d7a8 --- /dev/null +++ b/src/portfolio_optimiser/data/reference_projects.json @@ -0,0 +1,46 @@ +{ + "_note": "SYNTHETIC fixture (D4). Fictional 'anleggskostnad' projects with dummy data — not real estimates. Used as the framework's bundled reference portfolio.", + "currency": "NOK", + "projects": [ + { + "id": "FV42-GSV-E1", + "name": "Fv. 42 gang- og sykkelveg, etappe 1", + "description": "Ny gang- og sykkelveg langs Fv. 42, 1,2 km. Fiktivt referanseprosjekt.", + "currency": "NOK", + "cost_items": [ + {"code": "01.1", "description": "Rigg og drift", "quantity": 1, "unit": "rs", "unit_cost": 850000}, + {"code": "02.3", "description": "Masseutskifting bløt grunn", "quantity": 4200, "unit": "m3", "unit_cost": 420}, + {"code": "03.1", "description": "Forsterkningslag knust grus", "quantity": 1800, "unit": "m3", "unit_cost": 310}, + {"code": "05.2", "description": "Asfalt Ab11", "quantity": 4300, "unit": "m2", "unit_cost": 215}, + {"code": "07.4", "description": "Kantstein granitt", "quantity": 2400, "unit": "m", "unit_cost": 690}, + {"code": "09.1", "description": "Veglysmaster LED", "quantity": 34, "unit": "stk", "unit_cost": 28500} + ] + }, + { + "id": "RV13-RAS-TP", + "name": "Rv. 13 rassikring tunnelportal", + "description": "Rassikring og forlenget portal, Rv. 13. Fiktivt referanseprosjekt.", + "currency": "NOK", + "cost_items": [ + {"code": "01.1", "description": "Rigg og drift", "quantity": 1, "unit": "rs", "unit_cost": 1450000}, + {"code": "21.2", "description": "Sprengning fjell", "quantity": 3600, "unit": "m3", "unit_cost": 540}, + {"code": "22.1", "description": "Sikringsbolter Ø25", "quantity": 920, "unit": "stk", "unit_cost": 1250}, + {"code": "22.4", "description": "Sproytebetong fiberarmert", "quantity": 610, "unit": "m3", "unit_cost": 3850}, + {"code": "31.3", "description": "Drenering og bortledning", "quantity": 540, "unit": "m", "unit_cost": 1180} + ] + }, + { + "id": "BRU-LAKS-REHAB", + "name": "Bru over Lakselva — rehabilitering", + "description": "Betongrehabilitering og ny fuktisolering, eksisterende bru. Fiktivt referanseprosjekt.", + "currency": "NOK", + "cost_items": [ + {"code": "01.1", "description": "Rigg og drift", "quantity": 1, "unit": "rs", "unit_cost": 620000}, + {"code": "84.1", "description": "Stillas og tilkomst", "quantity": 1, "unit": "rs", "unit_cost": 540000}, + {"code": "88.2", "description": "Mekanisk betongreparasjon", "quantity": 180, "unit": "m2", "unit_cost": 4200}, + {"code": "87.3", "description": "Fuktisolering membran", "quantity": 640, "unit": "m2", "unit_cost": 980}, + {"code": "75.1", "description": "Rekkverk stal galvanisert", "quantity": 210, "unit": "m", "unit_cost": 2650} + ] + } + ] +} diff --git a/src/portfolio_optimiser/reference_domain.py b/src/portfolio_optimiser/reference_domain.py new file mode 100644 index 0000000..c5d5964 --- /dev/null +++ b/src/portfolio_optimiser/reference_domain.py @@ -0,0 +1,75 @@ +"""Synthetic reference domain (D4): a small, fictional set of "anleggskostnad" +(construction-cost) projects with dummy data. + +This is the framework's bundled example input — a portfolio of *independent* +projects the optimiser runs against. The framework finds cost-savings INSIDE +each project (Enhet B), so every project carries cost line items where a savings +measure could later be proposed and then deterministically validated. + +It is a synthetic FIXTURE — not real data, and not the validated IR. The +deliberate data-source *contract* (JSON-Schema-validated config, B5) is a Fase 2 +concern; here we keep a plain, typed loader over a bundled JSON file. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from importlib.resources import files + +_DATA_RESOURCE = "data/reference_projects.json" + + +@dataclass(frozen=True) +class CostItem: + """One cost line in a project's estimate.""" + + code: str + description: str + quantity: float + unit: str + unit_cost: float # NOK per unit + + @property + def total_cost(self) -> float: + return self.quantity * self.unit_cost + + +@dataclass(frozen=True) +class Project: + """One independent construction-cost project (Enhet B operates inside this).""" + + id: str + name: str + description: str + currency: str + cost_items: tuple[CostItem, ...] + + @property + def total_cost(self) -> float: + return sum((item.total_cost for item in self.cost_items), 0.0) + + +def load_reference_projects() -> tuple[Project, ...]: + """Load the bundled synthetic reference projects (D4).""" + resource = files("portfolio_optimiser").joinpath(_DATA_RESOURCE) + raw = json.loads(resource.read_text(encoding="utf-8")) + return tuple( + Project( + id=p["id"], + name=p["name"], + description=p["description"], + currency=p["currency"], + cost_items=tuple( + CostItem( + code=c["code"], + description=c["description"], + quantity=c["quantity"], + unit=c["unit"], + unit_cost=c["unit_cost"], + ) + for c in p["cost_items"] + ), + ) + for p in raw["projects"] + ) diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..64c601d --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,39 @@ +"""Tests for the backend profile skeleton (D2).""" + +import pytest + +from portfolio_optimiser.backends import ( + AzureFoundryBackend, + ChatBackend, + LocalBackend, + Profile, + get_backend, +) + + +def test_get_backend_by_string() -> None: + assert isinstance(get_backend("azure"), AzureFoundryBackend) + assert isinstance(get_backend("local"), LocalBackend) + + +def test_get_backend_by_enum() -> None: + assert get_backend(Profile.AZURE).profile is Profile.AZURE + assert get_backend(Profile.LOCAL).profile is Profile.LOCAL + + +def test_backends_satisfy_seam() -> None: + # Structural conformance to the ChatBackend Protocol (the D2 seam). + assert isinstance(get_backend("azure"), ChatBackend) + assert isinstance(get_backend("local"), ChatBackend) + + +def test_unknown_profile_fails_fast() -> None: + with pytest.raises(ValueError): + get_backend("on-prem") + + +@pytest.mark.parametrize("profile", ["azure", "local"]) +def test_create_chat_client_is_skeleton(profile: str) -> None: + # Fase 0: the seam is defined but not wired — must fail loudly, not silently. + with pytest.raises(NotImplementedError): + get_backend(profile).create_chat_client(model="dummy-model") diff --git a/tests/test_reference_domain.py b/tests/test_reference_domain.py new file mode 100644 index 0000000..f586846 --- /dev/null +++ b/tests/test_reference_domain.py @@ -0,0 +1,38 @@ +"""Tests for the synthetic reference domain (D4).""" + +import pytest + +from portfolio_optimiser.reference_domain import CostItem, Project, load_reference_projects + + +@pytest.fixture(scope="module") +def projects() -> tuple[Project, ...]: + return load_reference_projects() + + +def test_loads_non_empty(projects: tuple[Project, ...]) -> None: + assert len(projects) >= 1 + + +def test_project_ids_unique(projects: tuple[Project, ...]) -> None: + ids = [p.id for p in projects] + assert len(ids) == len(set(ids)) + + +def test_every_project_well_formed(projects: tuple[Project, ...]) -> None: + for p in projects: + assert p.id and p.name and p.description + assert p.currency == "NOK" + assert len(p.cost_items) >= 1 + + +def test_cost_item_total_is_quantity_times_unit_cost() -> None: + item = CostItem(code="X", description="d", quantity=4.0, unit="m2", unit_cost=215.0) + assert item.total_cost == pytest.approx(860.0) + + +def test_project_total_is_sum_of_items(projects: tuple[Project, ...]) -> None: + for p in projects: + expected = sum(item.total_cost for item in p.cost_items) + assert p.total_cost == pytest.approx(expected) + assert p.total_cost > 0