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:
parent
491a746bba
commit
b57aa83a30
5 changed files with 282 additions and 0 deletions
84
src/portfolio_optimiser/backends.py
Normal file
84
src/portfolio_optimiser/backends.py
Normal 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()
|
||||
46
src/portfolio_optimiser/data/reference_projects.json
Normal file
46
src/portfolio_optimiser/data/reference_projects.json
Normal 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}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
75
src/portfolio_optimiser/reference_domain.py
Normal file
75
src/portfolio_optimiser/reference_domain.py
Normal 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
39
tests/test_backends.py
Normal 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")
|
||||
38
tests/test_reference_domain.py
Normal file
38
tests/test_reference_domain.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue