Ctrl K
ComunidadGuías…

src/webui/services/assistant_authoring.py

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

Archivo original ↗Preguntar a KiBot
"""Agent authoring capability pack over canonical content-owner boundaries."""
from __future__ import annotations

import uuid
from copy import deepcopy
from dataclasses import dataclass
from typing import Callable

from src.application_actions import ActionAssessment, PreparedAction
from src.knowledge.visibility import classify_audience

from src.webui.services.assistant_agent import AgentTool, _digest
from src.webui.services.assistant_agent_application import obj, string
from src.webui.services.assistant_content import _has_private_markers
from src.webui.services.lorebook_visibility_plans import (
    LorebookVisibilityPlanDependencies, LorebookVisibilityPlans, audience_expands,
)


@dataclass(frozen=True)
class AuthoringDependencies:
    list_worlds: Callable
    update_world: Callable
    list_book_entries: Callable
    save_book_entry: Callable
    list_cards: Callable
    update_card: Callable
    adventure_detail: Callable
    get_rule: Callable
    write_entries_with_recovery: Callable | None = None
    write_world_with_recovery: Callable | None = None
    write_card_with_recovery: Callable | None = None
    world_snapshot: Callable | None = None
    card_snapshot: Callable | None = None
    game_players: Callable | None = None
    write_book_visibility_with_recovery: Callable | None = None
    preview_book_visibility_with_recovery: Callable | None = None
    book_snapshot: Callable | None = None
    book_visibility_receipt: Callable | None = None


def authoring_tools(deps: AuthoringDependencies) -> tuple[AgentTool, ...]:
    def live_players(game_key, owner):
        if deps.game_players is None:
            raise ValueError('Live game players unavailable')
        return deps.game_players(game_key, owner)

    visibility = None
    if (deps.book_snapshot is not None and deps.preview_book_visibility_with_recovery is not None
            and deps.write_book_visibility_with_recovery is not None and deps.book_visibility_receipt is not None):
        visibility = LorebookVisibilityPlans(LorebookVisibilityPlanDependencies(
            list_entries=deps.list_book_entries, snapshot=deps.book_snapshot,
            preview=deps.preview_book_visibility_with_recovery, write=deps.write_book_visibility_with_recovery,
            receipt=deps.book_visibility_receipt,
            game_players=live_players,
        ))
    def world(world_id):
        row = next((w for w in deps.list_worlds().get("worlds", []) if w.get("id") == world_id), None)
        if not row:
            raise ValueError("World does not exist")
        return row

    def card(card_id):
        row = next((c for c in deps.list_cards().get("cards", []) if c.get("id") == card_id), None)
        if not row:
            raise ValueError("Character card does not exist")
        return row

    def book_entries(book_id):
        result = deps.list_book_entries(book_id)
        if result.get("ok") is False:
            raise ValueError("Lorebook does not exist")
        return result.get("entries", [])

    async def read(args, owner):
        kind, identity = args["kind"], args["id"]
        if kind == "world":
            return {"ok": True, "world": world(identity)}
        if kind == "lorebook":
            return {"ok": True, "entries": book_entries(identity)}
        if kind == "character_template":
            return {"ok": True, "card": card(identity)}
        if kind == "adventure":
            return deps.adventure_detail(identity)
        return deps.get_rule(identity)

    async def edit_world(args, owner):
        current = world(args["world_id"])
        if any(_has_private_markers(str(args.get(field) or ""))
               for field in ("description", "world_setting", "starter_scene")):
            raise ValueError("World descriptions cannot contain private material")
        patch = {field: args[field] for field in ('name', 'description', 'language', 'world_setting', 'starter_scene')
                 if field in args}
        if not patch:
            raise ValueError('Select at least one world field to update')
        if deps.write_world_with_recovery is not None:
            return deps.write_world_with_recovery(owner, args['world_id'], patch)
        return deps.update_world(args["world_id"], patch.get('name', current.get('name', '')),
                                 patch.get('description', current.get('description', '')),
                                 patch.get('language', current.get('language') or 'zh-CN'),
                                 **{field: value for field, value in patch.items()
                                    if field in {"world_setting", "starter_scene"}})

    def entry_rows(args):
        entries = book_entries(args['book_id'])
        existing = {e["id"]: e for e in entries}
        prepared = []
        seen = set()
        # Validate the complete batch before the first write.
        for entry in args["entries"]:
            identity = entry.get("id")
            if identity and identity not in existing:
                raise ValueError("Entry ID does not exist in the selected book; omit it to create")
            if identity and identity in seen:
                raise ValueError("Duplicate entry update")
            seen.add(identity)
            public = (entry.get('visibility') == 'public' if 'visibility' in entry
                      else classify_audience(existing.get(identity)) == 'public')
            if public and _has_private_markers(entry["content"]):
                raise ValueError("A public entry cannot contain private markers")
            row = {key: deepcopy(value) for key, value in entry.items() if key != "visibility"}
            row["id"] = identity or f"agent_entry_{uuid.uuid4().hex}"
            # Preserve an existing entry's audience unless the user explicitly
            # confirms a visibility change. New entries fail closed to GM.
            if "visibility" in entry or not identity:
                row["visible_to"] = ["*"] if public else []
            prepared.append(row)
        return entries, existing, prepared

    def prepare_entries(args, owner):
        entries, existing, rows = entry_rows(args)
        expands = any(audience_expands(existing.get(row['id'], {}),
            row.get('visible_to', existing.get(row['id'], {}).get('visible_to', []))) for row in rows)
        digest = _digest(entries)
        return PreparedAction(digest, {'book_id': args['book_id'], 'entries': args['entries'],
            'requires_publication_consent': expands},
            {'book_id': args['book_id'], 'rows': rows, 'entries_digest': digest},
            ActionAssessment('disclosure' if expands else 'write', expands, ({'book_id': args['book_id']},)))

    async def execute_entries(payload, owner, context):
        context.check()
        return deps.write_entries_with_recovery(owner, payload['book_id'], payload['rows'],
                                                expected_entries_digest=payload['entries_digest'])

    async def write_entries(args, owner):
        _entries, _existing, prepared = entry_rows(args)
        if deps.write_entries_with_recovery is not None:
            return deps.write_entries_with_recovery(owner, args['book_id'], prepared)
        results = []
        for row in prepared:
            try:
                result = deps.save_book_entry(args["book_id"], row)
            except (ValueError, OSError) as exc:
                result = {"ok": False, "error": str(exc)}
            results.append({"id": row["id"], **result})
            if result.get("ok") is not True:
                return {"ok": False, "partial": len(results) > 1, "results": results,
                        "error": "Some entries could not be saved; inspect results before planning again"}
        return {"ok": True, "results": results, "navigation": {"route": "lorebook", "query": {"book": args["book_id"]}}}

    def prepare_entry_visibility(args, owner):
        existing, _audience, rows = visibility_plan(args, owner)
        expands = any(audience_expands(existing[row['id']], row['visible_to']) for row in rows)
        return PreparedAction(_digest(book_entries(args['book_id'])),
            {'book_id': args['book_id'], 'entry_ids': args['entry_ids'], 'audience': args['audience']}, {},
            ActionAssessment('disclosure' if expands else 'write', expands, ({'book_id': args['book_id']},)))

    def visibility_plan(args, owner):
        entries = book_entries(args["book_id"])
        existing = {str(entry.get("id")): entry for entry in entries
                    if isinstance(entry, dict) and isinstance(entry.get("id"), str)}
        entry_ids = args["entry_ids"]
        if len(set(entry_ids)) != len(entry_ids):
            raise ValueError("Duplicate entry ID")
        missing = [identity for identity in entry_ids if identity not in existing]
        if missing:
            raise ValueError("Entry ID does not exist in the selected book")

        audience = args["audience"]
        character_ids = args.get("character_ids", [])
        if audience == "characters":
            if not character_ids:
                raise ValueError("character_ids are required for character visibility")
            if not args.get("game_key") or deps.game_players is None:
                raise ValueError("game_key is required to validate live character identities")
            if len(set(character_ids)) != len(character_ids):
                raise ValueError("Duplicate character ID")
            # The composition callback owns game existence and GM scope
            # validation; do not resolve arbitrary game keys locally.
            players_result = deps.game_players(args["game_key"], owner)
            if isinstance(players_result, dict):
                players = players_result.get("players", players_result)
            else:
                players = players_result
            canonical_ids = (set(players) if isinstance(players, dict) else
                             {str(player.get("user_id")) for player in players
                              if isinstance(player, dict) and isinstance(player.get("user_id"), str)})
            unknown = [identity for identity in character_ids if identity not in canonical_ids]
            if unknown:
                raise ValueError("Unknown canonical character ID")
            visible_to = list(character_ids)
        elif character_ids:
            raise ValueError("character_ids are only valid for character visibility")
        else:
            visible_to = ["*"] if audience == "public" else []

        prepared = []
        for identity in entry_ids:
            row = deepcopy(existing[identity])
            if audience != "gm" and _has_private_markers(str(row.get("content") or "")):
                raise ValueError("A non-GM entry cannot contain private markers")
            row["visible_to"] = deepcopy(visible_to)
            prepared.append(row)
        return existing, visible_to, prepared

    async def set_entry_visibility(args, owner):
        """Change only audience markers on existing canonical entry rows."""
        _existing, _visible_to, prepared = visibility_plan(args, owner)
        if deps.write_entries_with_recovery is not None:
            return deps.write_entries_with_recovery(owner, args["book_id"], [
                {'id': row['id'], 'visible_to': row['visible_to']} for row in prepared])
        results = []
        for row in prepared:
            result = deps.save_book_entry(args["book_id"], row)
            results.append({"id": row["id"], **result})
            if result.get("ok") is not True:
                return {"ok": False, "partial": len(results) > 1, "results": results,
                        "error": "Some entries could not be saved; inspect results before planning again"}
        return {"ok": True, "results": results,
                "navigation": {"route": "lorebook", "query": {"book": args["book_id"]}}}

    async def set_book_visibility(args, owner):
        if deps.write_book_visibility_with_recovery is None:
            raise ValueError("Whole-book visibility is unavailable")
        entries = book_entries(args["book_id"])
        if len(entries) > 1000:
            raise ValueError("整册 visibility 最多支持 1000 条目,请按条目分批")
        # Reuse the same audience, canonical-player and private-marker checks
        # as the bounded entry action, while submitting only changed patches.
        _existing, _visible_to, prepared = visibility_plan({
            **args, "entry_ids": [str(row.get("id")) for row in entries]}, owner)
        expected = _digest(entries)
        changed = [{"id": row["id"], "visible_to": row["visible_to"]}
                   for row in prepared if row.get("visible_to") != _existing[row["id"]].get("visible_to")]
        result = deps.write_book_visibility_with_recovery(owner, args["book_id"], changed, expected)
        if hasattr(result, "__await__"):
            result = await result
        return {"ok": True, "book_id": args["book_id"], "audience": args["audience"],
                "total": len(entries), "changed_count": len(changed),
                **(result or {})}

    def book_visibility_preview(args, owner):
        entries = book_entries(args["book_id"])
        if len(entries) > 1000:
            raise ValueError("整册 visibility 最多支持 1000 条目,请按条目分批")
        existing, _visible_to, prepared = visibility_plan({
            **args, "entry_ids": [str(row.get("id")) for row in entries]}, owner)
        changed_count = sum(row.get("visible_to") != existing[row["id"]].get("visible_to") for row in prepared)
        changed = [{"id": row["id"], "visible_to": row["visible_to"]}
                   for row in prepared if row.get("visible_to") != existing[row["id"]].get("visible_to")]
        if deps.preview_book_visibility_with_recovery is not None:
            value = deps.preview_book_visibility_with_recovery(owner, args["book_id"], changed, _digest(entries))
            if hasattr(value, "__await__"):
                raise TypeError("book visibility preview callback must be synchronous")
            return {**value, "audience": args["audience"]}
        listing = deps.list_book_entries(args["book_id"])
        return {"target_name": str((listing.get("book") or {}).get("name") or args["book_id"]),
                "total": len(entries), "changed_count": changed_count,
                "audience": args["audience"], "batch_count": (changed_count + 15) // 16}

    def book_visibility_guard(args, owner):
        snapshot = (deps.book_snapshot(args["book_id"]) if deps.book_snapshot is not None
                    else deps.list_book_entries(args["book_id"]))
        return _digest({"snapshot": snapshot, "impact": book_visibility_preview(args, owner)})

    async def edit_card(args, owner):
        card(args["card_id"])
        patch = {key: value for key, value in args.items() if key != "card_id"}
        if any(_has_private_markers(str(value)) for value in patch.values()):
            raise ValueError("Character public fields cannot contain private material")
        if deps.write_card_with_recovery is not None:
            return deps.write_card_with_recovery(owner, args['card_id'], patch)
        # This service rejects professional-card legacy updates. No mechanics
        # fields are offered by the agent schema.
        return deps.update_card(args["card_id"], patch)

    entry = obj({"id": string(), "name": string(minimum=1), "content": string(16000, minimum=1),
        "type": string(30, ("npc", "location", "item", "event", "puzzle", "faction", "spell", "class", "other")),
        "keywords": {"type": "array", "items": string(100), "maxItems": 20},
        "visibility": string(20, ("public", "gm"))}, ("name", "content"))
    visibility_schema = obj({
        "book_id": string(minimum=1),
        "entry_ids": {"type": "array", "minItems": 1, "maxItems": 10,
                      "items": string(200, minimum=1)},
        "game_key": string(200, minimum=1),
        "audience": string(20, ("gm", "public", "characters")),
        "character_ids": {"type": "array", "maxItems": 10,
                          "items": string(200, minimum=1)},
    }, ("book_id", "entry_ids", "audience"))
    tools = [
        AgentTool("read_content", "Read an existing world, book, character, adventure or rule", obj({
            "kind": string(30, ("world", "lorebook", "character_template", "adventure", "rule")), "id": string(minimum=1)}, ("kind", "id")), read),
        AgentTool("edit_world", "Update the name and public setting of your world", obj({"world_id": string(minimum=1),
            "name": string(minimum=1), "description": string(16000), "language": string(16, ("zh-CN", "en", "de", "ja", "ru")),
            "world_setting": string(16000), "starter_scene": string(8192)},
            ("world_id",)), edit_world, mutation=True,
            guard=lambda args, owner: _digest(deps.world_snapshot(args['world_id']) if deps.world_snapshot else world(args["world_id"])),
            preview=lambda args, owner: {"target_name": world(args["world_id"])["name"], "changes": args}),
        AgentTool("write_lorebook_entries", "Add or update reviewed lorebook entries, with explicit public or GM visibility", obj({
            "book_id": string(minimum=1), "entries": {"type": "array", "minItems": 1, "maxItems": 16, "items": entry}},
            ("book_id", "entries")), write_entries, mutation=True, version=2,
            prepare=prepare_entries, execute=execute_entries if deps.write_entries_with_recovery is not None else None,
            guard=lambda args, owner: _digest(book_entries(args["book_id"])),
            preview=lambda args, owner: {"target_name": str((deps.list_book_entries(args["book_id"]).get("book") or {}).get("name") or args["book_id"]),
                "entries": args["entries"]}),
        AgentTool("set_lorebook_entry_visibility", "Change existing lorebook entry visibility without rewriting its authored fields",
            visibility_schema, set_entry_visibility, mutation=True, domain='lorebook', effect='disclosure',
            version=3, prepare=visibility.prepare if visibility is not None else prepare_entry_visibility,
            execute=visibility.execute if visibility is not None else None, resumable=visibility is not None,
            guard=lambda args, owner: _digest(book_entries(args["book_id"])),
            preview=lambda args, owner: {"target_name": str((deps.list_book_entries(args["book_id"]).get("book") or {}).get("name") or args["book_id"]),
                "entry_ids": args["entry_ids"], "audience": args["audience"],
                "character_ids": args.get("character_ids", [])}),
        *([AgentTool("set_lorebook_visibility", "Change visibility for every existing entry in a lorebook",
            obj({"book_id": string(minimum=1), "audience": string(20, ("gm", "public", "characters")),
                 "game_key": string(200, minimum=1), "character_ids": {"type": "array", "maxItems": 10,
                                 "items": string(200, minimum=1)}}, ("book_id", "audience")),
            set_book_visibility, mutation=True, domain="lorebook",
            effect="disclosure" if visibility is not None else "external", version=3,
            prepare=visibility.prepare if visibility is not None else None,
            execute=visibility.execute if visibility is not None else None, resumable=visibility is not None,
            guard=book_visibility_guard,
            preview=book_visibility_preview,
            instructions=("Narrowing to GM-only can execute under a covering Task grant. Audience expansion needs concrete consent. "
                          "For multiple books use set_lorebooks_visibility once with the complete explicit selection. "
                          "Changes only the audience of existing entries, never their text or world runtime facts. "
                          "Resolve the user's selection into explicit canonical book IDs first, then use one grouped command "
                          "for the selection. Ask for the target audience if it is unclear. Each book supports at most "
                          "1000 entries; larger books need explicit entry batches. The preview shows affected counts "
                          "and games; writes are transactional per book and have separate batch undo records."))]
            if deps.write_book_visibility_with_recovery is not None else []),
        *([AgentTool('set_lorebooks_visibility', 'Plan and apply visibility changes across multiple selected lorebooks',
            obj({'book_ids': {'type': 'array', 'minItems': 1, 'maxItems': 128, 'items': string(200, minimum=1)},
                 'audience': string(20, ('gm', 'public', 'characters')), 'game_key': string(200, minimum=1),
                 'character_ids': {'type': 'array', 'minItems': 1, 'maxItems': 10, 'items': string(200, minimum=1)}},
                ('book_ids', 'audience')), set_book_visibility, mutation=True, domain='lorebook', effect='disclosure',
            version=1, prepare=visibility.prepare, execute=visibility.execute, resumable=True,
            instructions=('For a request covering multiple or all lorebooks, read the live lorebook catalog to resolve '
                          'the complete explicit book_ids, then call this grouped command once. Do not split the request '
                          'into separate book approvals. GM-only narrowing runs automatically with covering Task access; '
                          'audience expansion presents one complete plan for approval, then all books execute. '
                          'Only visible_to changes. Failures and cancellation retain completed receipts and remaining '
                          'targets for explicit resume; inspect the structured execution report.'))] if visibility is not None else []),
        AgentTool("edit_character_card", "Edit a reusable character's public narrative fields", obj({"card_id": string(minimum=1),
            "character_name": string(minimum=1), "background": string(16000), "race": string(),
            "class": string()}, ("card_id", "character_name")), edit_card, mutation=True,
            guard=lambda args, owner: _digest(deps.card_snapshot(args['card_id']) if deps.card_snapshot else card(args["card_id"])),
            preview=lambda args, owner: {"target_name": card(args["card_id"])["character_name"], "changes": args}),
    ]
    return tuple(tools)