feat(persona): build the shared expert-reviewer persona as a framework-neutral Agent Skill
The expert reviewer was only a hardcoded verdict_input dict inside the offline simulation. Build it as the real, shared artifact target picture §8 calls for: shared/skills/expert-reviewer/ — a SKILL.md persona prompt (energy-advisor / M&V role + the realization-gap methodology the validator cannot compute) plus a canonical references/example-verdict.json. shared/ stays pure data; the MAF side reads it via portfolio_optimiser.persona.load_persona_example (call-time, fail-fast) and the Claude-SDK sibling reads the same JSON with its own loader. This de-stubs the simulation: its persona judgement (decision + rationale + traced marker) is now sourced from the artifact at call time, not an inline literal — so the shared persona is genuinely consumed and cannot rot silently. decision is binary (approved/rejected, the FeedbackContract the run path accepts); approved_with_adjustment is rejected there and lives only in the bundle seed frontmatter + the promotion gate, so the realization correction is carried in the rationale prose. Load-bearing trio (tests/test_persona_skill_loadbearing.py), each proven RED on its own detach: structure + framework-neutrality, the example is valid pipeline input (incl. FeedbackContract, on a throwaway copy), and the simulation's marker follows the artifact file. Suite 149->152. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
This commit is contained in:
parent
a9144cb9bb
commit
6f861a0078
8 changed files with 281 additions and 13 deletions
52
src/portfolio_optimiser/persona.py
Normal file
52
src/portfolio_optimiser/persona.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Loader for the shared expert-reviewer persona artifact (målbilde §8).
|
||||
|
||||
The persona lives in ``shared/skills/expert-reviewer/`` as a framework-neutral Agent Skill (SKILL.md
|
||||
+ a canonical example verdict). ``shared/`` stays pure DATA — this loader is the MAF-side reader of
|
||||
it; the Claude-SDK sibling reads the same JSON with its own loader. This is what de-stubs the
|
||||
offline simulation: its persona judgement is sourced from the artifact instead of a hardcoded
|
||||
literal, so the shared persona is genuinely consumed (and cannot rot silently).
|
||||
|
||||
Fail-fast on purpose: the example is REQUIRED input (contrast the tolerant async verdict inbox,
|
||||
``verdicts.load_verdicts_from_dir``). A missing or malformed file raises rather than degrading —
|
||||
a broken shared artifact must surface, not pass silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Module-global so tests can monkeypatch it; read at CALL time inside the loader (never frozen into
|
||||
# a default argument), which is what makes the simulation's persona genuinely artifact-driven.
|
||||
_EXAMPLE_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "shared"
|
||||
/ "skills"
|
||||
/ "expert-reviewer"
|
||||
/ "references"
|
||||
/ "example-verdict.json"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonaExample:
|
||||
"""The expert-reviewer persona's canonical example verdict: the judgement the persona renders for
|
||||
the reference measure. ``marker`` is a substring of ``rationale`` (the realization-rate payload
|
||||
the simulation traces across runs)."""
|
||||
|
||||
decision: str
|
||||
rationale: str
|
||||
marker: str
|
||||
|
||||
|
||||
def load_persona_example() -> PersonaExample:
|
||||
"""Read the persona's canonical example verdict. Fail-fast: a missing file raises
|
||||
``FileNotFoundError`` and a missing key raises ``KeyError`` (required input). Reads
|
||||
``_EXAMPLE_PATH`` at call time so the path is patchable."""
|
||||
data = json.loads(_EXAMPLE_PATH.read_text(encoding="utf-8"))
|
||||
return PersonaExample(
|
||||
decision=data["decision"],
|
||||
rationale=data["rationale"],
|
||||
marker=data["marker"],
|
||||
)
|
||||
|
|
@ -36,6 +36,7 @@ from agent_framework import (
|
|||
)
|
||||
from agent_framework_openai import OpenAIChatCompletionClient
|
||||
|
||||
from portfolio_optimiser.persona import load_persona_example
|
||||
from portfolio_optimiser.run import RunResult, run_project
|
||||
from portfolio_optimiser.validator import ValidatedProposal
|
||||
from portfolio_optimiser.verdicts import VerdictStore, promote_verdict, seed_store_from_bundle
|
||||
|
|
@ -52,14 +53,10 @@ _VALID_PROPOSAL = (
|
|||
# The checker's debate turn ends with the gate marker the run parses (run._checker_verdict).
|
||||
_CHECKER_APPROVE = "Tallene er innenfor feasibelt område og resonnementet holder. VERDICT: APPROVE"
|
||||
|
||||
# The persona's verdict: an APPROVE that carries NEW realization knowledge the validator cannot
|
||||
# compute. The marker (a realization rate ABSENT from the bundle — the seed is 0.82) is the payload
|
||||
# we trace from Run A's persona judgement, through promotion, into Run B's hypothesis prompt.
|
||||
_DEFAULT_MARKER = "realiseringsgrad=0.79"
|
||||
_DEFAULT_PERSONA_RATIONALE = (
|
||||
"Godkjent. I drift realiseres erfaringsvis ~79% av en timeplan-stipulert LED-besparelse i "
|
||||
f"kontorbygg ({_DEFAULT_MARKER}) pga overestimerte driftstimer; forventet faktisk ca 23700 NOK/aar."
|
||||
)
|
||||
# The persona's verdict is sourced from the shared expert-reviewer skill (``load_persona_example``),
|
||||
# NOT inlined here — that de-stubs the persona and makes the shared artifact genuinely consumed. Its
|
||||
# marker (a realization rate ABSENT from the bundle — the seed is 0.82) is the payload we trace from
|
||||
# Run A's persona judgement, through promotion, into Run B's hypothesis prompt.
|
||||
|
||||
|
||||
class ScriptedChatClient(OpenAIChatCompletionClient):
|
||||
|
|
@ -148,19 +145,29 @@ async def simulate_learning_loop(
|
|||
bundle_dir: str,
|
||||
work_dir: str,
|
||||
*,
|
||||
persona_rationale: str = _DEFAULT_PERSONA_RATIONALE,
|
||||
marker: str = _DEFAULT_MARKER,
|
||||
persona_rationale: str | None = None,
|
||||
marker: str | None = None,
|
||||
timestamp: str = "2026-06-30",
|
||||
max_rounds: int = 3,
|
||||
) -> LearningSimulationResult:
|
||||
"""Run the loop twice on a throwaway COPY of the bundle (the shared fixture is never mutated),
|
||||
with a promotion in between, and trace whether the persona's approved knowledge crosses runs.
|
||||
|
||||
The persona's verdict (decision + rationale + traced ``marker``) defaults to the shared
|
||||
expert-reviewer skill's canonical example (``load_persona_example``), read at CALL time — so the
|
||||
simulation is genuinely artifact-driven, not inlined. Callers may override ``marker`` /
|
||||
``persona_rationale`` for a control.
|
||||
|
||||
Run A: a fresh (empty) wiki -> an uninformed hypothesis; the persona approves with NEW realization
|
||||
knowledge (``marker`` in ``persona_rationale``). ``promote_verdict`` lifts that verdict into the
|
||||
wiki; ``seed_store_from_bundle`` re-reads the wiki; Run B's Step-1 ExpeL fold then carries the
|
||||
marker into its hypothesis prompt. The two runs use SEPARATE sinks so each prompt set is
|
||||
inspected independently."""
|
||||
example = load_persona_example()
|
||||
if marker is None:
|
||||
marker = example.marker
|
||||
if persona_rationale is None:
|
||||
persona_rationale = example.rationale
|
||||
if marker not in persona_rationale:
|
||||
raise ValueError("marker must be a substring of persona_rationale (the carried payload)")
|
||||
|
||||
|
|
@ -168,7 +175,7 @@ async def simulate_learning_loop(
|
|||
shutil.copytree(bundle_dir, copy)
|
||||
copy_s = str(copy)
|
||||
replies = {"proposer": _VALID_PROPOSAL, "checker": _CHECKER_APPROVE}
|
||||
verdict_input = {"decision": "approved", "rationale": persona_rationale}
|
||||
verdict_input = {"decision": example.decision, "rationale": persona_rationale}
|
||||
|
||||
# Run A — empty wiki isolates the persona's NEW knowledge.
|
||||
sink_a: list[str] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue