Files
chatballs/apps/backend/hub_platform/support/extract.py
T
Andrey caa51f4e29 test(support): cover token verify, session start and denials
SPEC §13.15 / §16.14 acceptance criteria.

- test_helpers.make_support_token: HS256-токен для тестов (зеркало token.py).
- SupportSessionTests: happy path (snapshot+conversation+audit SUCCESS), invalid
  signature, expired, schema error, missing subject, disabled contract, wrong
  channel (CHANNEL_NOT_SUPPORT), product mismatch, missing token, continue session
  (reuses open conversation), raw token/jti не попадает в audit.
- SupportContractApiTests: OWNER регистрирует контракт (version из code), OPERATOR
  forbidden (403), code должен соответствовать продукту.
- Багфиксы по ходу тестов: code=CharField (точки в foxray.support.v1), jsonpath
  return current, audit-deny вне write-транзакции (откат не стирает audit),
  DRAFT/DISABLED → CONTRACT_DISABLED, extraction вынесен в extract.py (NO GOD FILES).
- 109 passed (95 baseline + 14 support).
2026-07-09 01:20:04 +03:00

95 lines
4.0 KiB
Python

"""Извлечение identity/operator/ai/search контекста по контракту (SPEC-HUB-0011 §6-11).
Валидация payload по schema_json — ручная (нет jsonschema-зависимости):
проверка required-полей object и вложенных object-properties, pattern из orders/views.py.
Глубокая type-проверка — TODO.
"""
from __future__ import annotations
from typing import Any
from hub_platform.support import errors, jsonpath
from hub_platform.support.models import ProductSupportContract
def validate_schema(data: dict[str, Any], schema: dict[str, Any]) -> None:
"""Ручная проверка required-полей по schema. Поднимает PAYLOAD_SCHEMA_INVALID."""
if not schema:
return
required = schema.get("required") or []
if isinstance(required, list):
for field in required:
if field not in data:
raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID)
# Рекурсивная проверка required во вложенных object-properties.
properties = schema.get("properties") or {}
if isinstance(properties, dict):
for field, subschema in properties.items():
if not isinstance(subschema, dict):
continue
sub_required = subschema.get("required")
value = data.get(field)
if isinstance(sub_required, list) and isinstance(value, dict):
for sub_field in sub_required:
if sub_field not in value:
raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID)
def extract_context(data: dict[str, Any], contract: ProductSupportContract) -> dict[str, Any]:
"""Извлекает identity/operator_cards/ai_context/search по mapping'ам контракта."""
identity = contract.identity_mapping_json or {}
subject = jsonpath.resolve_path(data, identity.get("subject", ""))
if not subject:
raise errors.SupportSessionError(errors.SUBJECT_MAPPING_EMPTY)
account = jsonpath.resolve_path(data, identity.get("account", ""))
display_name = jsonpath.resolve_path(data, identity.get("display_name", "")) or ""
display_email = jsonpath.resolve_path(data, identity.get("display_email", "")) or ""
operator_cards = build_operator_cards(data, contract.operator_ui_json or {})
ai_context = build_ai_context(data, contract.ai_context_json or {})
search_text = build_search_text(data, contract.search_mapping_json or {})
return {
"subject_key": str(subject),
"account_key": str(account) if account else "",
"display_name": str(display_name),
"display_email": str(display_email),
"operator_context": {"operator_cards": operator_cards},
"ai_context": ai_context,
"search_text": search_text,
}
def build_operator_cards(data: dict[str, Any], ui: dict[str, Any]) -> list[dict[str, Any]]:
cards: list[dict[str, Any]] = []
for card in ui.get("operator_cards", []) or []:
fields = []
for field in card.get("fields", []) or []:
value = jsonpath.resolve_path(data, field.get("path", ""))
fields.append(
{
"label": field.get("label", ""),
"value": value,
"type": field.get("type", "text"),
"visibility": field.get("visibility", "operator"),
}
)
cards.append({"title": card.get("title", ""), "fields": fields})
return cards
def build_ai_context(data: dict[str, Any], ai: dict[str, Any]) -> dict[str, Any]:
allowed = ai.get("allowed_paths", []) or []
values: dict[str, Any] = {}
for path in allowed:
values[path] = jsonpath.resolve_path(data, path)
return {"allowed_paths": values}
def build_search_text(data: dict[str, Any], search: dict[str, Any]) -> str:
paths = search.get("paths", []) or []
parts = [str(jsonpath.resolve_path(data, p)) for p in paths]
return " ".join(p for p in parts if p and p != "None")