Ctrl K
ComunidadGuías…

src/webui/services/assistant_content.py

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

Archivo original ↗Preguntar a KiBot
"""AI authored content adapter.

This module only turns generated prose into the existing content-text review
contract.  Content owners remain responsible for validation and persistence;
the adapter never calls a direct world/adventure creation API.
"""

from __future__ import annotations

import re
import uuid
from dataclasses import dataclass
from typing import Any, Awaitable, Callable

from src.engine.language import SUPPORTED_LANGUAGES


_KINDS = frozenset({"world", "lorebook", "character_template", "adventure", "text"})
_MAX_PROMPT_CHARS = 8192
_MAX_LANGUAGE_CHARS = 16
_MAX_GENERATED_BYTES = 512 * 1024
_HEADING = re.compile(r"^#{1,3}\s+(.+?)\s*$")
_LEVEL_ONE_HEADING = re.compile(r"^#\s+.+$", re.IGNORECASE)
_EXPLICIT_ID = re.compile(r"\s*\{#[a-z0-9][a-z0-9_.-]*\}\s*$")
# Public prose may legitimately mention a secret, a host, or a hidden rumor.
# Treat only explicit section/line labels as visibility markers; a lexical
# occurrence inside an ordinary sentence is not enough to reject the draft.
_PRIVATE_HEADING_MARKER = re.compile(
    r"(?i)^(?:"
    r"gm(?:\s*[- ]\s*only)?(?:\s+(?:notes?|guidance|information|section))?|"
    r"secret(?:s)?(?:\s+(?:notes?|information|section))?|"
    r"private(?:\s+(?:notes?|information|section))?|"
    r"hidden(?:\s+(?:notes?|information|section))?|"
    r"秘密(?:信息|笔记|内容|段落|提示)?|"
    r"机密(?:信息|笔记|内容|段落|提示)?|"
    r"主持人(?:备注|笔记|信息|内容|段落|提示)?"
    r")\s*[::]?\s*$"
)
_PRIVATE_TEXT_MARKER = re.compile(
    r"(?i)^(?:"
    r"gm\s*:\s*|"
    r"gm\s*[- ]\s*only(?=\s|[::]|$)|"
    r"gm\s+(?:notes?|guidance|information|section)\s*[::]\s*|"
    r"(?:secret(?:s)?|private|hidden)(?:\s+(?:notes?|information|section))?\s*[::]\s*|"
    r"(?:秘密|机密|主持人(?:备注|笔记|信息|内容|段落|提示)?)\s*[::]\s*"
    r")"
)


@dataclass(frozen=True)
class AgentContentDependencies:
    """Callbacks supplied by the application composition root."""

    generate_text: Callable[[str, str, str], Awaitable[dict[str, Any]]]
    review_content: Callable[[dict[str, Any]], dict[str, Any]]
    commit_content: Callable[[dict[str, Any]], dict[str, Any]]


def _validate_kind(value: object) -> str:
    if not isinstance(value, str):
        raise TypeError("content kind must be a string")
    if value not in _KINDS:
        raise ValueError("unsupported content kind")
    return value


def _validate_prompt(value: object) -> str:
    if not isinstance(value, str):
        raise TypeError("content prompt must be a string")
    if not value.strip():
        raise ValueError("content prompt must be nonempty")
    if len(value) > _MAX_PROMPT_CHARS:
        raise ValueError("content prompt is too long")
    return value.strip()


def _validate_language(value: object) -> str:
    if not isinstance(value, str):
        raise TypeError("content language must be a string")
    if not value or len(value) > _MAX_LANGUAGE_CHARS or value not in SUPPORTED_LANGUAGES:
        raise ValueError("unsupported content language")
    return value


def _generated_failure(kind: str, error: object) -> dict[str, Any]:
    message = str(error or "text generation failed")
    return {"ok": False, "kind": kind, "error": message}


def _has_private_markers(text: str) -> bool:
    """Reject explicit private markers before text reaches a public owner."""

    for line in text.splitlines():
        stripped = line.strip()
        if re.match(r"(?i)^gm\s*:", stripped):
            return True
        heading = _HEADING.fullmatch(stripped)
        if heading and _PRIVATE_HEADING_MARKER.search(heading.group(1)):
            return True
        if _PRIVATE_TEXT_MARKER.search(stripped):
            return True
    return False


def _title(text: str, kind: str) -> str:
    for line in text.splitlines():
        match = _HEADING.fullmatch(line.strip())
        if match:
            value = _EXPLICIT_ID.sub("", match.group(1)).strip()
            if value:
                return value[:200]
    return {
        "world": "AI World",
        "lorebook": "AI Lorebook",
        "character_template": "AI Character Template",
        "adventure": "AI Adventure",
    }[kind]


def _system_hint(kind: str, language: str) -> str:
    if kind in {"world", "lorebook", "character_template"}:
        audience = (
            "This target currently accepts a player-safe public-only draft. Do not include GM notes, hidden "
            "information, secrets, or private sections; GM-only hidden material is supported only for adventures."
        )
    elif kind == "adventure":
        audience = (
            "Adventure scripts may include public material and GM-only notes. Put a GM-only note on a line "
            "beginning exactly with `GM:`; this private marker is supported only by the adventure parser."
        )
    else:
        audience = "Answer as ordinary text; this result is informational and is not a content commit candidate."
    common = (
        f"You author DiceFrame content in language `{language}`. Return complete, usable Markdown prose only; "
        "do not return JSON, metadata, commentary, or a surrounding code fence. Make the content sufficiently "
        f"detailed to use immediately. {audience} Do not invent unsupported protocol markers or hidden mechanics."
    )
    if kind == "world":
        return common + (
            " Create one coherent world setting with its identity, places, factions, conflicts, tone, and useful "
            "starting context. Keep all setting descriptions player-safe and public."
        )
    if kind == "lorebook":
        return common + (
            " Create one coherent lorebook as a single complete document. Include a title and organized Markdown "
            "sections when useful; keep related knowledge together instead of producing separate books."
        )
    if kind == "character_template":
        return common + (
            " Create one complete reusable character template with identity, appearance, personality, background, "
            "motivations, relationships, and public guidance. Do not assert rules statistics as authoritative mechanics."
        )
    if kind == "adventure":
        return common + (
            " Create one playable adventure script. Use only the markers supported by DiceFrame's script parser: "
            "level 1-3 Markdown headings (`#`, `##`, `###`) for title/chapter/scene, optional `{#scene-id}` on "
            "scene headings, `GM:` lines for GM notes, `@npc value`, `@location value`, `@encounter value`, and "
            "`@choice label -> scene-id` for choices. Put each marker on its own line, keep scene ids lowercase "
            "ASCII, and do not make up other control syntax."
        )
    return common + " Answer the user's request directly as ordinary prose."


def _body(kind: str, text: str, language: str) -> dict[str, Any]:
    content_text = text
    if kind == "adventure":
        # ContentTextReview.prepare adds the canonical outer ``# title``.
        # Dropping only a duplicate generated level-one title preserves all
        # subsequent parser headings, chapters, scenes, and GM notes.
        lines = text.splitlines()
        if lines and _LEVEL_ONE_HEADING.fullmatch(lines[0].strip()):
            content_text = "\n".join(lines[1:]).strip()
    return {
        "source_id": f"agent-{uuid.uuid4()}",
        "filename": "agent.md",
        "language": language,
        # Keep a lorebook as one complete book fragment. The existing review
        # service can split user-authored text, but doing so here would turn
        # one generated work into many independent resources.
        "items": [{
            "id": f"{kind}_content",
            "name": _title(text, kind),
            "text": content_text,
            "target": kind,
            "references": [],
        }],
    }


def _reject_private_public_items(body: dict[str, Any]) -> None:
    """Enforce visibility from the edited item's actual target, not outer kind."""

    items = body.get("items")
    if not isinstance(items, list):
        return
    for item in items:
        if not isinstance(item, dict):
            continue
        target = item.get("target")
        text = item.get("text")
        if target in {"world", "lorebook", "character_template"} and isinstance(text, str):
            if _has_private_markers(text):
                raise ValueError("private or hidden markers cannot be saved to public content")


class AgentContentTools:
    """Draft, review, and commit AI-authored content through existing owners."""

    def __init__(self, deps: AgentContentDependencies):
        self.deps = deps

    async def draft(self, kind: str, prompt: str, language: str) -> dict[str, Any]:
        kind = _validate_kind(kind)
        prompt = _validate_prompt(prompt)
        language = _validate_language(language)
        generated = await self.deps.generate_text(prompt, _system_hint(kind, language), language)
        if not isinstance(generated, dict) or generated.get("ok") is not True:
            error = generated.get("error") if isinstance(generated, dict) else "invalid text generation response"
            return _generated_failure(kind, error)
        text = generated.get("text")
        if not isinstance(text, str) or not text.strip() or len(text.encode("utf-8")) > _MAX_GENERATED_BYTES:
            return _generated_failure(kind, "generated content is empty or too large")
        text = text.strip()
        if kind == "text":
            return {"ok": True, "kind": "text", "text": text}
        if kind in {"world", "lorebook", "character_template"} and _has_private_markers(text):
            return _generated_failure(kind, "generated content contains private or hidden markers")
        checked = self.deps.review_content(_body(kind, text, language))
        if not isinstance(checked, dict):
            raise TypeError("content review must return an object")
        return {**checked, "kind": kind}

    def review(self, draft: dict[str, Any]) -> dict[str, Any]:
        if not isinstance(draft, dict):
            raise TypeError("content draft must be an object")
        kind = _validate_kind(draft.get("kind"))
        if kind == "text":
            raise ValueError("text drafts are not commit candidates")
        body = draft.get("draft")
        if not isinstance(body, dict):
            raise ValueError("reviewable content draft is required")
        _reject_private_public_items(body)
        checked = self.deps.review_content(body)
        if not isinstance(checked, dict):
            raise TypeError("content review must return an object")
        return {**checked, "kind": kind}

    def commit(self, checked: dict[str, Any]) -> dict[str, Any]:
        if not isinstance(checked, dict):
            raise TypeError("review result must be an object")
        kind = _validate_kind(checked.get("kind"))
        if kind == "text":
            raise ValueError("text drafts cannot be committed")
        blockers = checked.get("blockers", [])
        if not isinstance(blockers, list):
            raise ValueError("content review blockers must be a list")
        if blockers:
            raise ValueError("content review has blocking issues")
        body = checked.get("draft")
        if not isinstance(body, dict):
            raise ValueError("review result does not contain a draft")
        _reject_private_public_items(body)
        token = checked.get("plan_digest")
        if not isinstance(token, str) or not token:
            raise ValueError("content review token is required")
        return self.deps.commit_content({**body, "reviewed": True, "plan_digest": token})