Ctrl K
ComunidadGuías…

src/webui/services/adventure_script_import.py

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

Archivo original ↗Preguntar a KiBot
"""Script review/commit application boundary; native Adventure owner persists it."""

from __future__ import annotations

import io
import json
import zipfile
from copy import deepcopy
from pathlib import PurePosixPath
from typing import Any

from src.adventures.bundle import AdventureBundleLoader
from src.adventures.graph_v2 import reachable_nodes, validate_graph_v2
from src.adventures.script_import import parse_script
from src.adventures.external_import import parse_import
from src.content_modules.refs import ContentDraft, build_commit_plan, commit_plan_digest, parse_content_ref
from src.webui.services import adventures
from src.webui.services.adventures import _user_adventure_root


def preview(deps: adventures.AdventureDependencies, payload: bytes, filename: str, language: str) -> dict[str, Any]:
    draft = parse_import(payload, filename, deps.default_runtime_requirement(), language)
    return review(deps, draft)


def prepare_text_adventure(deps: adventures.AdventureDependencies, text: str, directory_id: str, language: str) -> dict[str, Any]:
    """Prepare a prose fragment at its reviewed identity, including locale targets."""
    directory = adventures.validate_directory_id(directory_id)
    draft = parse_script(text.encode(), f"{directory}.md", deps.default_runtime_requirement(), language)
    old_id = draft["files"]["adventure.json"]["id"]
    draft["directory_id"] = directory
    draft["files"]["manifest.json"]["adventure_id"] = f"user:{directory}"
    draft["files"]["adventure.json"]["id"] = directory
    for reference in draft["references"]:
        if reference.get("source_kind") == "adventure" and reference.get("source_id") == old_id:
            reference["source_id"] = directory
    for value in draft["files"].values():
        target = value.get("target") if isinstance(value, dict) else None
        if isinstance(target, dict) and target.get("kind") == "adventure" and target.get("id") == old_id:
            target["id"] = directory
    return review(deps, draft)


def review(deps: adventures.AdventureDependencies, draft: dict[str, Any]) -> dict[str, Any]:
    """Recompute plan for the *edited* draft; temporary validation leaves no content behind."""
    user_root = _user_adventure_root(deps)
    files = deepcopy(draft.get('files'))
    if not isinstance(files, dict):
        raise ValueError('script draft files are required')
    for name in files:
        if not isinstance(name, str) or '\\' in name or ':' in name or any(part.startswith('.') for part in PurePosixPath(name).parts):
            raise ValueError('unsafe script draft file path')
    directory = adventures.validate_directory_id(draft.get('directory_id'))
    manifest = files.get('manifest.json')
    if not isinstance(manifest, dict):
        raise ValueError('script draft manifest is required')
    identity = adventures._validate_package_id(manifest.get('adventure_id'))
    if manifest.get('format') != 'diceframe:adventure-graph-v2':
        raise ValueError('script drafts require Adventure graph v2')
    provenance = draft.get('provenance') or {}
    if not isinstance(provenance, dict):
        raise ValueError('script provenance must be an object')
    # Source metadata remains descriptive. It never grants rule authority.
    manifest['import_source'] = deepcopy(provenance)
    refs = draft.get('references', [])
    if not isinstance(refs, list) or len(refs) > 256:
        raise ValueError('script references must be a bounded array')
    references = [parse_content_ref(ref, default_source=f'adventure:{directory}') for ref in refs]
    warnings = ['scriptDefaultGm', 'scriptMechanicsReference']
    warnings.extend(code for code in ('storyDraftGmReview', 'storyDraftNpcReferencesOmitted') if code in draft.get('warnings', []))
    if 'scriptUnknownMarker' in draft.get('warnings', []):
        warnings.append('scriptUnknownMarker')
    warnings.extend(code for code in ('externalImportReference', 'externalImportMedia', 'externalImportScriptsOmitted', 'pdfReviewLayout') if code in draft.get('warnings', []))
    blockers: list[str] = []
    try:
        adventures._ensure_identity_available(deps, identity)
        if (user_root / directory).exists():
            raise ValueError('adventure directory already exists; choose another identity')
    except ValueError as exc:
        blockers.append(str(exc))
    available = []
    try:
        staged, temporary = adventures._validated_stage(deps, directory, files)
        try:
            bundle = AdventureBundleLoader(staged.parent).load(directory)
            graph = validate_graph_v2(bundle.adventure)
            reachable = reachable_nodes(graph, graph['start_node_ids'])
            if any(n['id'] not in reachable and not n['optional'] for n in graph['nodes']):
                warnings.append('scriptUnreachableScenes')
            for node in graph['nodes']:
                for field in ('scene_ref', 'encounter_ref'):
                    if node.get(field):
                        references.append(parse_content_ref(node[field], default_source=f'adventure:{directory}'))
                references.extend(parse_content_ref(ref, default_source=f'adventure:{directory}') for ref in node['npc_refs'])
            for kind, records in bundle.entities.items():
                available.extend(parse_content_ref(f'{kind}:{key}', default_source=f'adventure:{directory}') for key in records)
        finally:
            temporary.cleanup()
    except ValueError as exc:
        blockers.append(str(exc))
    # Portable Draft refs have explicit sources. Normalize generated bare refs
    # to that same shape before deduplication, so re-review is a fixed point.
    references = list(dict.fromkeys(parse_content_ref(ref.to_portable_dict(), default_source=f'adventure:{directory}') for ref in references))
    clean_draft = {'directory_id': directory, 'files': files, 'references': [r.to_portable_dict() for r in references],
                   'provenance': provenance, 'warnings': warnings}
    content = ContentDraft(kind='adventure', source_kind='user', source_id=directory, external_id=identity,
                           payload=clean_draft, references=tuple(dict.fromkeys(references)), provenance=clean_draft['provenance'])
    plan = build_commit_plan([content], available=available)
    if plan.unresolved_references:
        blockers.append('scriptUnresolvedReferences')
    return {'ok': True, 'draft': clean_draft, 'content_draft': content.to_portable_dict(),
            'commit_plan': plan.to_portable_dict(), 'plan_digest': commit_plan_digest(plan),
            'warnings': warnings, 'blockers': blockers}


def commit(deps: adventures.AdventureDependencies, body: dict[str, Any]) -> dict[str, Any]:
    if body.get('reviewed') is not True:
        raise ValueError('script must be reviewed before publishing')
    checked = review(deps, body)
    if checked['blockers']:
        raise ValueError('script draft has blocking issues; review it again')
    if checked['plan_digest'] != str(body.get('plan_digest') or ''):
        raise ValueError('script preview is stale; review the edited draft again')
    draft = checked['draft']
    output = io.BytesIO()
    with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
        for path, value in draft['files'].items():
            archive.writestr(f'{draft["directory_id"]}/{path}', json.dumps(value, ensure_ascii=False))
    return adventures.import_adventure(deps, output.getvalue(), draft['directory_id'])