dodil/erp-brick-consolidation

DODIL ERP brick: consolidation

src/brick_consolidation/infrastructure/ai_steps.py

"""AI steps (erp-roadmap #53, the AI-step contract): an AI step is a PROVIDER of a capability, with the
ManualTask as its fallback and its reviewer. The mode is part of the signed BPMN: off | suggest (the
default: AI drafts, a person approves) | auto-with-review | auto (only a step marked safe_for_auto; never
destructive or money-moving without approval).

TODO(ai): erp-platform has no step-mode API yet. Until it lands, `suggest` is this brick's job: the use
case stores the provider's draft as a ManualTask for a person to approve, and never acts on it alone.
Generated by `erp-compose scaffold` from module.yaml.
"""
from erp_platform.capabilities import CapabilitySeam

# What every AI action leaves behind (the AI-step contract): the provider's action log holds the model,
# the inputs, the output and the confidence; the brick records the approver — who accepted the ManualTask,
# or reviewed an auto-with-review result.
AUDIT = ("model", "inputs", "output", "confidence", "approver")

AI_STEPS = {
    "explain_elimination_difference": {
        "capability": "ai.suggest",
        "mode": "suggest",
        "modes_allowed": ["off", "suggest"],
        "safe_for_auto": False,
        "fallback": "the group accountant works the difference by hand",
        "benefit": "a pair whose intercompany balances differ comes with a likely cause (an invoice in one month and the bill in the next, a different rate, a missing recharge)",
        "inputs": ["the pair's reported data", "gl's intercompany match for the pair", "the two companies' rates"],
        "eval": "seeded differences of each kind; accuracy of the cause",
    },
}

SEAMS = {
    "explain_elimination_difference": CapabilitySeam("ai.suggest"),
}


def propose(step: str, idempotency_key: str, input: dict, mode: str):
    """Ask the step's provider for a proposal. `off` → None (the process is the fallback). Otherwise the
    provider's Result, or — no provider, or none wired — the ManualTask with the exact input (rule 8). What
    happens next is the caller's, by the mode: suggest → a ManualTask a person accepts, edits or rejects;
    auto-with-review → apply it and open a review task; auto → apply it (safe_for_auto steps only).
    The provider never applies anything."""
    spec = AI_STEPS[step]
    if mode == "off":
        return None
    if mode not in spec["modes_allowed"]:
        raise ValueError(f"{step}: mode {mode!r} is not allowed ({', '.join(spec['modes_allowed'])})")
    if mode == "auto" and not spec["safe_for_auto"]:
        raise ValueError(f"{step} is not safe for auto: a person approves it")
    if step not in SEAMS:
        return None                     # no capability yet: the fallback, a person (the note above)
    return SEAMS[step].call(idempotency_key, input)