feat(fase0): synthetic reference domain (D4) + backend profile skeleton (D2)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9FyyENxebxVThjrn9et8C
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 22:38:41 +02:00
commit b57aa83a30
5 changed files with 282 additions and 0 deletions

View file

@ -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()

View file

@ -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}
]
}
]
}

View file

@ -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"]
)

39
tests/test_backends.py Normal file
View file

@ -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")

View file

@ -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