feat(vocabulary): backend for native tags and categories (#241)
* feat(tags): keep unused tags in the shared vocabulary * feat(vocabulary): add bounded tag and category reads * feat(vocabulary): guard tag create, rename and merge * feat(vocabulary): preview and apply category consolidation * refactor(vocabulary): share canonical token hashing * test(vocabulary): prove retained tags survive restore * test(vocabulary): cover category paging tie-break and clarify contract docs * test(vocabulary): assert rejected tag creations write nothing
This commit is contained in:
@@ -24,6 +24,7 @@ from PIL import Image
|
||||
from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .vocabulary import VocabularyRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
@@ -246,6 +247,7 @@ async def _gzip_middleware(request, handler):
|
||||
class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
VocabularyRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
@@ -406,6 +408,7 @@ class PromptManagerAPI(
|
||||
|
||||
self._register_pair_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_vocabulary_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
|
||||
@@ -169,6 +169,9 @@ _ADD_NEGATIVE_FIELDS = frozenset({"expected_epoch", "if_match", "negative_text"}
|
||||
_COMMAND_MESSAGES = {
|
||||
"metadata-conflict": "This prompt changed since it was loaded; review it first",
|
||||
"storage-changed": "Prompt storage was restored; refresh the library",
|
||||
"vocabulary-conflict": (
|
||||
"This tag or category changed since it was loaded; review it first"
|
||||
),
|
||||
}
|
||||
_GENERIC_CONFLICT = "Library command conflicted; refresh the library"
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Native vocabulary: bounded, signed-cursor reads and guarded tag and
|
||||
category commands."""
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .library import (
|
||||
_command_body,
|
||||
_decode,
|
||||
_encode,
|
||||
_scope,
|
||||
library_command_response,
|
||||
)
|
||||
from .pairs import pair_response
|
||||
|
||||
_DEFAULT_LIMIT = 100
|
||||
_CREATE_TAG_FIELDS = frozenset({"expected_epoch", "name"})
|
||||
_RENAME_TAG_FIELDS = frozenset({"expected_epoch", "name", "new_name"})
|
||||
_DELETE_TAG_FIELDS = frozenset({"expected_epoch", "name"})
|
||||
_PREVIEW_CATEGORY_FIELDS = frozenset({"expected_epoch", "source", "target"})
|
||||
_APPLY_CATEGORY_FIELDS = _PREVIEW_CATEGORY_FIELDS | {"impact_token"}
|
||||
|
||||
|
||||
def _tag_id(request: web.Request) -> int:
|
||||
try:
|
||||
value = int(request.match_info["tag_id"])
|
||||
except ValueError:
|
||||
raise ValueError("Invalid tag ID") from None
|
||||
if not 1 <= value <= 2**63 - 1:
|
||||
raise ValueError("Invalid tag ID")
|
||||
return value
|
||||
|
||||
|
||||
def _vocabulary_limit(request: web.Request) -> int:
|
||||
value = int(request.query.get("limit", str(_DEFAULT_LIMIT)))
|
||||
if not 1 <= value <= 200:
|
||||
raise ValueError("limit must be between 1 and 200")
|
||||
return value
|
||||
|
||||
|
||||
class VocabularyRoutesMixin:
|
||||
def _register_vocabulary_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/vocabulary/tags")(self.list_vocabulary_tags)
|
||||
routes.post("/prompt_manager/vocabulary/tags")(self.create_vocabulary_tag)
|
||||
routes.patch("/prompt_manager/vocabulary/tags/{tag_id}")(
|
||||
self.rename_vocabulary_tag
|
||||
)
|
||||
routes.post("/prompt_manager/vocabulary/tags/{tag_id}/delete")(
|
||||
self.delete_vocabulary_tag
|
||||
)
|
||||
routes.get("/prompt_manager/vocabulary/tags/{tag_id}/impact")(
|
||||
self.get_vocabulary_tag_impact
|
||||
)
|
||||
routes.get("/prompt_manager/vocabulary/categories")(
|
||||
self.list_vocabulary_categories
|
||||
)
|
||||
routes.post("/prompt_manager/vocabulary/categories/preview")(
|
||||
self.preview_vocabulary_category
|
||||
)
|
||||
routes.post("/prompt_manager/vocabulary/categories/apply")(
|
||||
self.apply_vocabulary_category
|
||||
)
|
||||
|
||||
async def _vocabulary_page(
|
||||
self, request: web.Request, kind: str, reader_name: str, name: bool
|
||||
) -> web.Response:
|
||||
limit = _vocabulary_limit(request)
|
||||
cursor = request.query.get("cursor")
|
||||
|
||||
def read() -> dict:
|
||||
# The snapshot stays open while the cursor is decoded and encoded,
|
||||
# so its epoch scope is the epoch the page was read from.
|
||||
with self.db.library_snapshot() as (conn, epoch, _ceiling):
|
||||
scope = _scope({"kind": kind, "epoch": epoch})
|
||||
result = getattr(self.db, reader_name)(
|
||||
conn, epoch, limit=limit, after=_decode(cursor, scope)
|
||||
)
|
||||
result["next_cursor"] = _encode(
|
||||
result.pop("next_key"), scope, name=name
|
||||
)
|
||||
return result
|
||||
|
||||
result = await self._run_in_executor(read)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@pair_response
|
||||
async def list_vocabulary_tags(self, request: web.Request) -> web.Response:
|
||||
return await self._vocabulary_page(
|
||||
request, "vocabulary-tags", "_vocabulary_tags", False
|
||||
)
|
||||
|
||||
@pair_response
|
||||
async def list_vocabulary_categories(self, request: web.Request) -> web.Response:
|
||||
return await self._vocabulary_page(
|
||||
request, "vocabulary-categories", "_vocabulary_categories", True
|
||||
)
|
||||
|
||||
@library_command_response
|
||||
async def create_vocabulary_tag(self, request: web.Request) -> web.Response:
|
||||
body = await _command_body(request, _CREATE_TAG_FIELDS)
|
||||
result = await self._run_in_executor(
|
||||
self.db.create_vocabulary_tag,
|
||||
body["name"],
|
||||
expected_epoch=body["expected_epoch"],
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@library_command_response
|
||||
async def rename_vocabulary_tag(self, request: web.Request) -> web.Response:
|
||||
tag_id = _tag_id(request)
|
||||
body = await _command_body(request, _RENAME_TAG_FIELDS)
|
||||
result = await self._run_in_executor(
|
||||
self.db.rename_vocabulary_tag,
|
||||
tag_id,
|
||||
expected_epoch=body["expected_epoch"],
|
||||
name=body["name"],
|
||||
new_name=body["new_name"],
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@library_command_response
|
||||
async def delete_vocabulary_tag(self, request: web.Request) -> web.Response:
|
||||
tag_id = _tag_id(request)
|
||||
body = await _command_body(request, _DELETE_TAG_FIELDS)
|
||||
result = await self._run_in_executor(
|
||||
self.db.delete_vocabulary_tag,
|
||||
tag_id,
|
||||
expected_epoch=body["expected_epoch"],
|
||||
name=body["name"],
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@pair_response
|
||||
async def get_vocabulary_tag_impact(self, request: web.Request) -> web.Response:
|
||||
result = await self._run_in_executor(
|
||||
self.db.vocabulary_tag_impact, _tag_id(request)
|
||||
)
|
||||
if result is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Tag not found"}, status=404
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@library_command_response
|
||||
async def preview_vocabulary_category(self, request: web.Request) -> web.Response:
|
||||
body = await _command_body(request, _PREVIEW_CATEGORY_FIELDS)
|
||||
result = await self._run_in_executor(
|
||||
self.db.preview_vocabulary_category,
|
||||
expected_epoch=body["expected_epoch"],
|
||||
source=body["source"],
|
||||
target=body["target"],
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@library_command_response
|
||||
async def apply_vocabulary_category(self, request: web.Request) -> web.Response:
|
||||
body = await _command_body(request, _APPLY_CATEGORY_FIELDS)
|
||||
result = await self._run_in_executor(
|
||||
self.db.apply_vocabulary_category,
|
||||
expected_epoch=body["expected_epoch"],
|
||||
source=body["source"],
|
||||
target=body["target"],
|
||||
impact_token=body["impact_token"],
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
Reference in New Issue
Block a user