Ctrl K
ComunidadGuías…

src/commands/economy_effects.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Narrative economy gates that do not create charges.

Purchases are created only by the explicit GM purchase-order service. Model
output can describe a payment, but it cannot create a payment proposal.
"""

from __future__ import annotations

from copy import deepcopy
from typing import Any

from src.engine.intent.economy_intent import has_economy_proposal
from src.engine.intent.parser import completed_payment_pattern, currency_labels_for_rule
from src.engine.currency import parse_currency_amount
from src.engine.intent.locale_messages import locale_message

_DEFERRED_DATA_KEYS = {
    "confirmed", "growth_skills", "info_asymmetry", "memory_delta",
    "milestone_grants", "plot_update", "quick_actions", "scene_image_prompt",
    "xp_rewards",
}
def _meaningful(value: Any) -> bool:
    if isinstance(value, dict):
        return any(_meaningful(item) for item in value.values())
    if isinstance(value, (list, tuple, set)):
        return any(_meaningful(item) for item in value)
    return value not in {None, "", False, 0}


def should_warn_unbacked_payment(
    narration: str,
    data: dict[str, Any],
    language: str,
    *,
    currency_labels: Any = None,
) -> bool:
    """Return whether narration describes a payment without an authority proposal."""

    text = str(narration or "").strip()
    if not text or has_economy_proposal(data):
        return False
    return bool(completed_payment_pattern(language, currency_labels).search(text))


def unbacked_payment_notice(language: str) -> str:
    return locale_message(language, "unbacked_payment_notice")


def guard_unbacked_payment_narration(narration: str, data: dict[str, Any], language: str, *, currency_labels: Any = None) -> str:
    text = str(narration or "").strip()
    if not should_warn_unbacked_payment(
        text, data, language, currency_labels=currency_labels,
    ):
        return text
    return f"{text}\n\n{unbacked_payment_notice(language)}"


def unbacked_purchase_notice(language: str) -> str:
    return locale_message(language, "unbacked_purchase_notice")


def unpriced_purchase_notice(language: str) -> str:
    """Explain why a purchase intent did not create a chargeable order."""

    return locale_message(language, "unpriced_purchase_notice")


def defer_narrative_effects(data: dict[str, Any], response: Any, *, defer_state_update: bool = True) -> dict[str, Any]:
    if not has_economy_proposal(data):
        return {}
    state_update = dict(data.get("state_update") or {})
    immediate = {
        key: deepcopy(value)
        for key, value in state_update.items()
        if not defer_state_update or key in {"economy_proposals"}
    }
    deferred_state = {
        key: deepcopy(value)
        for key, value in state_update.items()
        if defer_state_update and key not in immediate and _meaningful(value)
    }
    deferred: dict[str, Any] = {}
    if deferred_state:
        deferred["state_update"] = deferred_state
    data["state_update"] = immediate
    response.state_update = immediate
    completion_candidate = None
    for key in _DEFERRED_DATA_KEYS:
        value = data.get(key)
        if _meaningful(value):
            if key == "plot_update" and isinstance(value, dict):
                deferred_plot = deepcopy(value)
                completion_candidate = deferred_plot.pop("adventure_completion", None)
                if _meaningful(deferred_plot):
                    deferred[key] = deferred_plot
            else:
                deferred[key] = deepcopy(value)
        if key == "memory_delta":
            data[key] = {"add": [], "update": [], "forget": []}
        elif key == "plot_update":
            data[key] = {
                "quests": [], "relations": [], "decisions": [],
                "adventure_completion": completion_candidate,
            }
        elif key == "info_asymmetry":
            data[key] = {}
        elif key in {"confirmed", "growth_skills", "milestone_grants", "quick_actions"}:
            data[key] = []
        elif key == "xp_rewards":
            data[key] = {}
        else:
            data[key] = ""
    response.memory_delta = data.get("memory_delta", {})
    response.info_asymmetry = data.get("info_asymmetry", {})
    response.plot_update = data.get("plot_update", {})
    return deferred


def pending_decision_notice(language: str) -> str:
    return locale_message(language, "pending_decision_notice")


def infer_narrative_reward_candidates(
    instance: Any,
    narration: str,
    rule: Any = None,
) -> list[dict[str, Any]]:
    """Extract explicit narrative reward offers without changing balances.

    This is a fallback for a GM response that narrated a payment but emitted
    no ``REWARD`` tag, so it is deliberately narrow:

    - only the GM's narration is evidence; the player's own action text is
      never read, so a player cannot write themselves a reward;
    - one acting player, and the paying sentence must address that player
      (second person or their character name) and name no other player;
    - an explicit reward/payment cue directly before an Arabic amount and a
      declared currency unit;
    - price quotes, per-item/each splits, conditional promises and the player
      paying someone else are rejected.

    The result is still a pending reward proposal; this helper never mutates a
    character sheet and never treats a bare balance mention as a reward.
    """

    import re

    text = str(narration or "")
    if not text or not instance.players:
        return []
    actor_ids = [
        str(action.get("user_id") or "")
        for action in instance.action_queue
        if str(action.get("user_id") or "") in instance.players
    ]
    if len(set(actor_ids)) != 1:
        return []
    uid = actor_ids[0]
    actor_name = str(instance.players[uid].get("character_name") or "").strip()
    other_names = {
        str(player.get("character_name") or "").strip()
        for other_uid, player in instance.players.items()
        if other_uid != uid and isinstance(player, dict)
    } - {"", actor_name}
    spec = getattr(rule, "currency_spec", None)
    if spec is None:
        return []
    labels: list[tuple[str, str]] = []
    for unit in spec.units:
        for label in (str(unit.id or "").strip(), str(unit.name or "").strip(), str(unit.symbol or "").strip()):
            if label:
                labels.append((label, unit.id))
    if not labels:
        return []
    label_pattern = "|".join(re.escape(label) for label, _ in sorted(labels, key=lambda item: len(item[0]), reverse=True))
    # Require a reward/payment cue right before the amount. This excludes
    # balance/status narration such as “余额 710 金币”. A bare “给你” only
    # counts when the amount follows it directly (“给你710金币”), because
    # “给你看看……800金币” is a quote, not a payment.
    pattern = re.compile(
        rf"(?:(?:奖励|报酬|酬金|酬劳|赏金|赏给你|支付给你|付给你|"
        rf"rewards?|rewarded|bounty|pays you|paid you)[^0-90-9]{{0,16}}|给你\s*)"
        rf"(?P<amount>[0-9]+)\s*(?P<label>{label_pattern})",
        re.IGNORECASE,
    )
    # A sentence with any of these is not a payment to the actor right now:
    # a price quote or per-item split, a conditional/future promise, or the
    # player character paying someone else.
    not_a_payment = re.compile(
        r"卖|售|价|多少钱|每|一共|总共|共计|收费|花费|要价|开价|"
        r"如果|若是|只要|答应|承诺|将会|事成|你们|"
        r"你[^,,;;::]{0,8}?(?:支付|付了|付给|付清|交给|掏出|花了|花费)|"
        r"\b(?:costs?|sells?|sold|price[sd]?|for sale|each|per|charges?|"
        r"if|once|will|would|promises?|you all|you pay|you paid|you spend|you hand)\b",
        re.IGNORECASE,
    )
    addressed = re.compile(
        r"你|您|\byou\b" + (rf"|{re.escape(actor_name)}" if actor_name else ""),
        re.IGNORECASE,
    )
    matches: list[tuple[Any, str]] = []
    for sentence in re.split(r"[。!?!?\n]+|(?<=\.)\s+", text):
        sentence = sentence.strip()
        if not sentence or not_a_payment.search(sentence):
            continue
        if not addressed.search(sentence) or any(name in sentence for name in other_names):
            continue
        matches.extend((match, sentence) for match in pattern.finditer(sentence))
    if not matches:
        return []
    parsed_matches: list[tuple[Any, str, Any, str]] = []
    for match, sentence in matches:
        label = match.group("label")
        unit_id = next((unit for raw, unit in labels if raw.casefold() == label.casefold()), "")
        if not unit_id:
            return []
        try:
            amount = parse_currency_amount(match.group("amount"), unit_id, spec)
        except Exception:
            return []
        parsed_matches.append((match, unit_id, amount, sentence))
    # The narration may repeat the same explicit reward. Repeated identical
    # evidence is one candidate; conflicting amounts or units remain
    # ambiguous and fail closed.
    identities = {(int(amount), str(unit_id)) for _, unit_id, amount, _ in parsed_matches}
    if len(identities) != 1:
        return []
    match, unit_id, amount, sentence = parsed_matches[0]
    if amount <= 0:
        return []
    return [{
        "kind": "reward",
        "uid": uid,
        "recipient_uid": uid,
        "amount": amount,
        "reason": sentence[:240],
        "evidence": match.group(0).strip()[:300],
        "evidence_validated": True,
        "source": "narrative_reward_extractor",
    }]


def merge_narrative_reward_candidates(
    data: dict[str, Any],
    candidates: list[dict[str, Any]],
) -> int:
    """Append prose reward candidates only when the response has no structured reward.

    The extractor is a fallback for a GM who narrated a payment without a
    ``REWARD`` tag. When the model did emit a reward proposal, that channel
    owns the response's rewards: its reason text rarely matches the prose
    sentence, so trying to match the two would credit one payment twice.
    """

    state_update = data.setdefault("state_update", {})
    if not isinstance(state_update, dict):
        return 0
    proposals = state_update.setdefault("economy_proposals", [])
    if not isinstance(proposals, list):
        proposals = []
        state_update["economy_proposals"] = proposals
    if any(
        isinstance(proposal, dict) and str(proposal.get("kind") or "") == "reward"
        for proposal in proposals
    ):
        return 0

    def identity(proposal: Any) -> tuple[str, str, int] | None:
        if not isinstance(proposal, dict) or str(proposal.get("kind") or "") != "reward":
            return None
        uid = str(proposal.get("recipient_uid") or proposal.get("uid") or "").strip()
        try:
            amount = int(proposal.get("amount"))
        except (TypeError, ValueError):
            return None
        if not uid or amount <= 0:
            return None
        return ("reward", uid, amount)

    existing = [proposal for proposal in proposals if identity(proposal) is not None]
    added = 0
    for candidate in candidates:
        key = identity(candidate)
        if key is None:
            continue
        candidate_evidence = str(candidate.get("evidence") or "").strip().casefold()
        duplicate = False
        for proposal in existing:
            if identity(proposal) != key:
                continue
            proposal_evidence = str(proposal.get("evidence") or "").strip().casefold()
            # If both sides carry evidence, only the same evidence is one
            # reward. A missing evidence field is legacy structured output, so
            # retain the old amount-based de-duplication for that compatibility
            # case instead of crediting the same response twice.
            if not candidate_evidence or not proposal_evidence or candidate_evidence == proposal_evidence:
                duplicate = True
                break
        if duplicate:
            continue
        proposals.append(candidate)
        existing.append(candidate)
        added += 1
    return added