src/commands/economy_planner.py
Rama main · réplica de lectura del repositorio original.
"""Ruleset-neutral economy planning and quote validation for the round planner.
This module owns purchase intent normalization, evidence checks, and the
compatibility fallback used when the semantic planner is unavailable.
"""
from __future__ import annotations
import re
from typing import Any
from src.engine.currency import legacy_currency_spec, parse_currency_amount
from src.engine.currency.validation import CurrencySystemError
from src.engine.economy import MAX_ECONOMY_AMOUNT
from src.engine.game_instance import GameInstance
from src.engine.intent.locale_messages import locale_message
from src.engine.intent.parser import (
parse_purchase_intents,
purchase_balance_pattern,
purchase_generic_target_terms,
purchase_intent_pattern,
purchase_offer_pattern,
purchase_quantity_classifier_pattern,
purchase_quantity_words,
purchase_target_connector_pattern,
purchase_target_prefix_pattern,
purchase_target_suffix_pattern,
)
from src.engine.language import localized_text
from src.llm.parser import sanitize_narration
from src.rules.rule_system import RuleSystem
def _match_player(instance: GameInstance, value: object) -> str:
query = str(value or "").strip().casefold()
if not query:
return ""
for uid, player in instance.players.items():
if query in {uid.casefold(), str(player.get("character_name") or "").strip().casefold()}:
return uid
return ""
def append_unpriced_purchase_intent(
unpriced: list[dict[str, Any]],
seen: set[tuple[str, str, int]],
*,
uid: str,
target: str,
quantity: int,
) -> None:
"""Record one deduplicated unpriced purchase intent (round memory only)."""
key = (uid, target.casefold(), quantity)
if key in seen:
return
seen.add(key)
unpriced.append({
"payer_uid": uid,
"target": target,
"quantity": quantity,
})
def _infer_explicit_currency_unit(
instance: GameInstance,
uid: str,
rule: RuleSystem | None,
*,
amount: Any,
price_source: str,
) -> str:
"""Recover a missing unit only from an exact amount+unit phrase in trusted text.
The planner may omit ``unit`` even when the player/GM clearly wrote
``34金币``. Recovering that label server-side preserves valid purchases
without defaulting an unknown ``银币`` quote to the display unit. If the
amount/unit pair is not explicit or is ambiguous, callers must fail closed.
"""
currency_spec = rule.currency_spec if rule else legacy_currency_spec("")
source_texts: list[str] = []
if price_source == "player_stated":
source_texts = [
str(action.get("text") or "")
for action in instance.action_queue
if str(action.get("user_id") or "") == str(uid)
]
elif price_source == "gm_narrated":
source_texts = [
sanitize_narration(str(entry.get("gm_response") or ""))
for entry in instance.log[-2:]
if entry.get("gm_response")
]
text = "\n".join(source_texts)
raw_amount = str(amount or "").strip()
if not text or not raw_amount:
return ""
matches: set[str] = set()
for unit in currency_spec.units:
labels = [str(unit.id or "").strip(), str(unit.name or "").strip()]
if unit.symbol:
labels.append(str(unit.symbol).strip())
for label in labels:
if not label:
continue
pattern = rf"{re.escape(raw_amount)}\s*(?:枚|个)?\s*{re.escape(label)}"
if re.search(pattern, text, flags=re.IGNORECASE):
matches.add(unit.id)
break
return next(iter(matches)) if len(matches) == 1 else ""
def _quote_evidence_is_trusted(
instance: GameInstance,
uid: str,
price_source: str,
evidence: Any,
) -> bool:
"""Verify model-provided quote evidence against server-owned text."""
excerpt = re.sub(r"\s+", " ", str(evidence or "")).strip()
if not excerpt or len(excerpt) > 300:
return False
if price_source == "player_stated":
sources = [
str(action.get("text") or "")
for action in instance.action_queue
if str(action.get("user_id") or "") == str(uid)
]
elif price_source == "gm_narrated":
sources = [
sanitize_narration(str(entry.get("gm_response") or ""))
for entry in instance.log[-2:]
if entry.get("gm_response")
]
else:
return False
return any(excerpt in re.sub(r"\s+", " ", source) for source in sources)
def normalize_economy_actions(
instance: GameInstance,
raw_actions: list[Any],
rule: RuleSystem | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
"""校验模型经济报价并归一到付款人。
新货币协议:``amount`` 是十进制字符串、``unit`` 是规则货币的 canonical
unit id;服务端经 CurrencyCodec 转成 canonical base-unit 整数,模型绝不
自行换算。price_source=none 或缺价时安全跳过(没有人说出价格就不产生
扣款提案),有效的意图以 ``unpriced`` 形式返回,供本轮 LOOT 拦截使用;
它们只存在于回合内存中,从不入库,也从不产生金额。amount 存在则
price_source 必须是明确的转述来源。单条无效不影响同批。
意图与报价分层:player/target/quantity 校验通过后购买意图即成立;
之后任何「报价不可结算」(unit 无法映射、金额无法 canonicalize、
amount_scope/price_source 非法、超出上限)都只降级为 unpriced intent
并记录 error——绝不因为价格解析失败让购买意图消失,否则 LOOT gate
会放行免费发放。fail closed:可以不收费,但不能免费交货。
"""
currency_spec = rule.currency_spec if rule else legacy_currency_spec("")
offers: list[dict[str, Any]] = []
unpriced: list[dict[str, Any]] = []
errors: list[str] = []
seen_unpriced: set[tuple[str, str, int]] = set()
def downgrade_to_unpriced(index: int, reason: str) -> None:
errors.append(f"economy_actions[{index}] {reason};已降级为 unpriced purchase intent")
append_unpriced_purchase_intent(
unpriced, seen_unpriced,
uid=uid, target=target, quantity=quantity,
)
for index, raw in enumerate(raw_actions[:8]):
if not isinstance(raw, dict):
errors.append(f"economy_actions[{index}] 不是 object")
continue
if str(raw.get("type") or "") != "purchase":
errors.append(f"economy_actions[{index}] type 仅支持 purchase")
continue
uid = _match_player(instance, raw.get("player"))
if not uid:
errors.append(f"economy_actions[{index}] player 不存在")
continue
target = str(raw.get("target") or "").strip()[:120]
if not target:
errors.append(f"economy_actions[{index}] target 为空")
continue
quantity = 1
if raw.get("quantity") is not None:
try:
quantity = int(raw.get("quantity") or 1)
except (TypeError, ValueError):
errors.append(f"economy_actions[{index}] quantity 无效")
continue
if not 1 <= quantity <= 8:
errors.append(f"economy_actions[{index}] quantity 超出范围")
continue
# —— 以上通过后购买意图已可靠成立;以下失败只降级,不丢弃 ——
price_source = str(raw.get("price_source") or "").strip()
if price_source != "none" and raw.get("amount") is None:
# 有目标但没有金额:与 price_source=none 同样处理为无价意图。
price_source = "none"
if price_source == "none":
append_unpriced_purchase_intent(
unpriced, seen_unpriced,
uid=uid, target=target, quantity=quantity,
)
continue
amount_scope = str(raw.get("amount_scope") or "total").strip()
if amount_scope not in {"unit", "total"}:
downgrade_to_unpriced(index, f"amount_scope={amount_scope!r} 无效")
continue
if price_source not in {"player_stated", "gm_narrated"}:
downgrade_to_unpriced(index, f"price_source={price_source!r} 无效")
continue
evidence = str(raw.get("evidence") or "").strip()[:300]
if evidence and not _quote_evidence_is_trusted(instance, uid, price_source, evidence):
downgrade_to_unpriced(index, "evidence 与可信文本不匹配")
continue
unit_input = str(raw.get("unit") or "").strip()
if not unit_input:
unit_input = _infer_explicit_currency_unit(
instance,
uid,
rule,
amount=raw.get("amount"),
price_source=price_source,
)
# 金额报价必须携带 canonical unit。过去在 unit 缺失时回退到
# display unit,导致“34 银币”被模型省略 unit 后按“34 金币”扣款。
# 缺失或未知单位一律降级为无价意图,不能猜测换算。
if not unit_input:
downgrade_to_unpriced(index, "unit 缺失")
continue
unit = currency_spec.unit(unit_input)
if unit is None:
downgrade_to_unpriced(
index, f"unit={unit_input!r} 不在当前规则货币单位中",
)
continue
try:
amount = parse_currency_amount(raw.get("amount"), unit.id, currency_spec)
except CurrencySystemError as exc:
downgrade_to_unpriced(index, str(exc))
continue
if amount > MAX_ECONOMY_AMOUNT:
downgrade_to_unpriced(index, "amount 超出范围")
continue
total_amount = amount * quantity if amount_scope == "unit" else amount
if not 0 < total_amount <= MAX_ECONOMY_AMOUNT:
downgrade_to_unpriced(index, "total amount 超出范围")
continue
initial_due = None
if raw.get("deposit_amount") is not None:
try:
initial_due = parse_currency_amount(raw.get("deposit_amount"), unit.id, currency_spec)
except CurrencySystemError as exc:
downgrade_to_unpriced(index, f"deposit_amount {exc}")
continue
if not 0 < initial_due < total_amount:
downgrade_to_unpriced(index, "deposit_amount 必须小于 total amount")
continue
max_total_raw = raw.get("max_total")
max_total = None
if max_total_raw is not None:
try:
max_total = parse_currency_amount(max_total_raw, unit.id, currency_spec)
except CurrencySystemError as exc:
downgrade_to_unpriced(index, f"max_total {exc}")
continue
if not 0 <= max_total <= MAX_ECONOMY_AMOUNT:
downgrade_to_unpriced(index, "max_total 超出范围")
continue
if total_amount > max_total:
downgrade_to_unpriced(index, "报价超过玩家预算上限")
continue
offer = {
"payer_uid": uid,
# Downstream proposal settlement always receives the total charge.
"amount": total_amount,
"quantity": quantity,
"amount_scope": amount_scope,
"target": target,
"note": str(raw.get("note") or "").strip()[:160],
}
if evidence:
offer["evidence"] = evidence
if max_total is not None:
offer["max_total"] = max_total
if initial_due is not None:
offer["initial_due"] = initial_due
offer["remaining_due"] = total_amount - initial_due
offers.append(offer)
# A repair pass may add a valid quote after the first pass recorded the
# same intent as unpriced. The chargeable offer is the stronger authority;
# do not keep a stale unpriced blocker for that same payer/item/quantity.
priced_keys = {
(str(offer.get("payer_uid") or ""), str(offer.get("target") or "").casefold(), int(offer.get("quantity", 1) or 1))
for offer in offers
}
unpriced = [
intent for intent in unpriced
if (
str(intent.get("payer_uid") or ""),
str(intent.get("target") or "").casefold(),
int(intent.get("quantity", 1) or 1),
) not in priced_keys
]
return offers, unpriced, errors
def _clean_deterministic_purchase_target(
context: str,
language: str = "",
*,
after_verb: bool = False,
) -> tuple[str, int]:
"""Clean the parser's item hint without inventing an item identity.
``after_verb`` marks text that already starts right after the purchase
verb. Subject/connector words (“我要”“再”“还”) belong before the verb,
so stripping them there would only cut the item's own first character:
“再生药水” → “生药水”, “还魂丹” → “魂丹”.
"""
target = re.sub(r"\s+", " ", str(context or "")).strip()
if not target:
return "", 1
if not after_verb:
target = purchase_target_prefix_pattern(language or "zh-CN").sub("", target, count=1)
target = re.split(
purchase_offer_pattern(language or "zh-CN").pattern,
target,
maxsplit=1,
flags=re.IGNORECASE,
)[0]
# A compact player action may put the explicit quote directly after the
# item (“买护甲,34金币”) without a price marker. Strip only a trailing
# numeric+label clause; the amount itself is parsed separately from the
# original action and never inferred from this hint.
target = re.sub(
r"[,,]\s*\d+(?:\.\d+)?\s*(?:枚|个)?\s*[\w\u3400-\u9fff]+$",
"",
target,
flags=re.IGNORECASE,
)
# The generic intent parser removes the currency token before it removes
# the purchase verb. Strip balance/connector residue left by phrases such
# as “我有34金币再买护甲”, without treating that balance as a quote.
target = purchase_balance_pattern(language or "zh-CN").sub("", target, count=1)
if not after_verb:
target = purchase_target_connector_pattern(language or "zh-CN").sub("", target, count=1)
target = re.sub(r"^[\s,,。.、::;;\-]+|[\s,,。.、::;;\-]+$", "", target)
target = purchase_target_suffix_pattern(language or "zh-CN").sub("", target)
quantity = 1
classifier = purchase_quantity_classifier_pattern(language or "zh-CN")
numeric = re.match(rf"^(?P<qty>[0-9]+)\s*{classifier.pattern}", target, flags=re.IGNORECASE)
if numeric:
quantity = int(numeric.group("qty"))
target = target[numeric.end():]
else:
quantity_words = purchase_quantity_words(language or "zh-CN")
word_pattern = "|".join(
re.escape(word) for word in sorted(quantity_words, key=len, reverse=True)
)
chinese = re.match(rf"^(?P<qty>{word_pattern})\s*{classifier.pattern}", target, flags=re.IGNORECASE) if word_pattern else None
if chinese:
quantity = quantity_words[chinese.group("qty")]
target = target[chinese.end():]
target = re.sub(r"\s+", " ", target).strip()
# A bare amount+label (“34金币”) is money, not an item. Checked after the
# quantity prefix is consumed so “3瓶药水” still yields 药水 ×3.
if re.fullmatch(r"\d+(?:\.\d+)?\s*[\w㐀-鿿]+", target):
return "", 1
generic_targets = {
str(value).casefold()
for value in purchase_generic_target_terms(language or "zh-CN")
}
if not target or target.casefold() in generic_targets:
return "", quantity
if not 1 <= quantity <= 8:
return "", quantity
return target[:120], quantity
def _purchase_target_from_action(action_text: str, language: str) -> tuple[str, int]:
"""Extract the text immediately following the player's purchase verb."""
text = str(action_text or "")
pattern = purchase_intent_pattern(language or "zh-CN")
tails: list[str] = []
for sentence in re.split(r"[。!?.!?\n]+", text):
matches = list(pattern.finditer(sentence))
if matches:
tails.append(sentence[matches[-1].end():])
if not tails:
return "", 1
return _clean_deterministic_purchase_target(tails[-1], language, after_verb=True)
def _recover_purchase_intents_from_player_actions(
instance: GameInstance,
rule: RuleSystem | None,
offers: list[dict[str, Any]],
unpriced: list[dict[str, Any]],
) -> None:
"""Backstop a missing model economy field with deterministic player intent.
This only records unpriced blockers, so a purchase the model failed to
report can never become a silent free delivery. It never creates a
chargeable offer: prices come from the planner reporting what a human
stated (ADR 0002), not from keyword/regex reads of currency mentions,
which misread per-item prices (“每瓶5金币”) and thousands separators
(“1,500”). The GM can always price the order with the manual composer.
"""
extra_labels = [
label
for unit in (getattr(getattr(rule, "currency_spec", None), "units", ()) or ())
for label in (getattr(unit, "id", ""), getattr(unit, "name", ""), getattr(unit, "symbol", ""))
if str(label or "").strip()
]
intents = parse_purchase_intents(
instance.action_queue,
instance.players,
str(getattr(instance, "language", "") or ""),
extra_labels,
)
known = {
(str(item.get("payer_uid") or ""), str(item.get("target") or "").casefold(), int(item.get("quantity", 1) or 1))
for item in [*offers, *unpriced]
if isinstance(item, dict)
}
priced_actors = {
str(item.get("payer_uid") or "")
for item in offers
if isinstance(item, dict) and str(item.get("payer_uid") or "")
}
for intent in intents:
# The parser's item hint can carry an explicit quantity prefix; do not
# infer anything beyond that hint.
target, quantity = _purchase_target_from_action(
intent.action_text,
str(getattr(instance, "language", "") or ""),
)
if not target:
target, quantity = _clean_deterministic_purchase_target(
intent.item_context,
str(getattr(instance, "language", "") or ""),
)
if not target:
continue
# Model output for this actor already carries an economy decision. Do
# not append a second heuristic target from the same action; this is
# especially important when the model's target is more precise than
# the parser's loose item hint.
if str(intent.actor_uid) in priced_actors:
continue
key = (str(intent.actor_uid), target.casefold(), quantity)
if key in known:
continue
# A model/repair pass may already have preserved this actor's
# unpriced intent. Keep that target rather than append a noisier
# parser hint.
if any(
str(item.get("payer_uid") or "") == str(intent.actor_uid)
for item in unpriced
if isinstance(item, dict)
):
continue
unpriced.append({
"payer_uid": str(intent.actor_uid),
"target": target,
"quantity": quantity,
})
known.add(key)
priced_keys = {
(
str(item.get("payer_uid") or ""),
str(item.get("target") or "").casefold(),
int(item.get("quantity", 1) or 1),
)
for item in offers
if isinstance(item, dict)
}
unpriced[:] = [
item for item in unpriced
if (
str(item.get("payer_uid") or ""),
str(item.get("target") or "").casefold(),
int(item.get("quantity", 1) or 1),
) not in priced_keys
]