Skip to content

Commit 8fbedae

Browse files
authored
Merge pull request #1938 from Open-Source-Legal/claude/nice-thompson-mp7ND
Add agent tool to manually regenerate a corpus icon
2 parents 258b7fc + cb26cc2 commit 8fbedae

8 files changed

Lines changed: 651 additions & 8 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
- **Manually (re)generate a corpus icon via an agent.** A new
2+
`regenerate_corpus_icon` agent tool
3+
(`opencontractserver/llms/tools/core_tools/corpus_branding.py`) re-runs the
4+
corpus logo generator on demand, so a user can ask the corpus chat agent to
5+
give a collection a fresh icon at any time — not just at creation. It reuses
6+
the auto-branding primitives (`_build_logo_prompt` + `agenerate_logo_image`
7+
with the deterministic PIL monogram fallback) through a new public helper
8+
`aregenerate_corpus_logo` in `opencontractserver/corpuses/services/branding.py`
9+
and persists through the creator-gated `CorpusService.update_icon`. An optional
10+
`additional_instructions` argument lets the agent steer the look (e.g. "use
11+
blue tones and a gavel motif"); the hint is sanitised and length-capped
12+
(`CORPUS_LOGO_ADDITIONAL_INSTRUCTIONS_MAX_CHARS`) before being folded into the
13+
image prompt. The tool is registered in `tool_registry.py` (alias
14+
`generate_corpus_icon`), `requires_approval` + `requires_corpus` +
15+
`requires_write_permission`, creator-only, and is wired into the interactive
16+
corpus agent's authenticated toolset
17+
(`opencontractserver/llms/agents/pydantic_ai_agents.py`) alongside
18+
`update_corpus_description`. Unlike the create-time path it deliberately
19+
overwrites an existing icon and ignores `auto_branding_enabled` (a manual
20+
regeneration is an explicit request). Tests:
21+
`opencontractserver/tests/test_corpus_icon_tool.py`.

opencontractserver/constants/corpus_branding.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@
5555
CORPUS_LOGO_REQUEST_TIMEOUT_SECONDS = 90.0
5656
CORPUS_LOGO_CONNECT_TIMEOUT_SECONDS = 10.0
5757

58+
# Upper bound on the free-text styling hint an agent/user may pass to the manual
59+
# ``regenerate_corpus_icon`` tool. The hint is sanitised
60+
# (``sanitize_plaintext_for_prompt``) and capped at this length before being
61+
# appended to the image prompt, so a long crafted value cannot dominate the
62+
# prompt or break out of it.
63+
CORPUS_LOGO_ADDITIONAL_INSTRUCTIONS_MAX_CHARS = 500
64+
5865
# ---------------------------------------------------------------------------
5966
# PIL monogram fallback
6067
# ---------------------------------------------------------------------------

opencontractserver/corpuses/services/branding.py

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
if TYPE_CHECKING:
2828
from opencontractserver.corpuses.models import Corpus
2929
from opencontractserver.llms.api import ToolType
30+
from opencontractserver.shared.services.conventions import ServiceResult
31+
from opencontractserver.users.models import User
3032

3133
logger = logging.getLogger(__name__)
3234

@@ -176,6 +178,84 @@ def _save() -> str:
176178
return await _save()
177179

178180

181+
async def aregenerate_corpus_logo(
182+
corpus: Corpus,
183+
user: User,
184+
*,
185+
additional_instructions: str | None = None,
186+
) -> ServiceResult[None]:
187+
"""Generate a fresh logo and persist it to ``corpus.icon`` (creator-gated).
188+
189+
The manual counterpart to :func:`_generate_logo`: it builds the same
190+
text-to-image prompt (optionally augmented with caller-supplied
191+
``additional_instructions``), generates the image via
192+
:func:`agenerate_logo_image` (OpenAI Images with the deterministic PIL
193+
monogram fallback), and writes it through
194+
:meth:`CorpusService.update_icon`.
195+
196+
Unlike the auto-branding path this **deliberately overwrites** any existing
197+
icon and ignores ``auto_branding_enabled`` — a manual regeneration is an
198+
explicit request, not the create-time best-effort default. Authorisation is
199+
still enforced: ``update_icon`` is creator-only, so the call is routed
200+
through the freshly re-loaded acting user and a non-creator receives a
201+
failure result with no write performed.
202+
203+
The corpus and user rows are re-loaded inside the save's
204+
``database_sync_to_async`` boundary (mirroring :func:`_generate_logo`) so
205+
the ORM write runs against that thread's connection and honours any state
206+
change — icon upload, hard delete — that landed during the slow image
207+
generation.
208+
209+
Returns the :class:`ServiceResult` from ``update_icon`` (success carries
210+
``None``; failure carries a human-readable reason).
211+
"""
212+
from channels.db import database_sync_to_async
213+
214+
from opencontractserver.utils.image_generation import agenerate_logo_image
215+
216+
prompt = _build_logo_prompt(corpus, additional_instructions)
217+
image_bytes, ext = await agenerate_logo_image(
218+
prompt=prompt,
219+
fallback_text=corpus.title or "Corpus",
220+
fallback_seed=str(corpus.pk),
221+
)
222+
223+
corpus_pk = corpus.pk
224+
user_pk = user.pk
225+
226+
@database_sync_to_async
227+
def _save() -> ServiceResult[None]:
228+
# Deferred imports keep sync ORM access inside the
229+
# database_sync_to_async boundary and avoid a circular import
230+
# (corpus_service -> branding).
231+
from django.contrib.auth import get_user_model
232+
233+
from opencontractserver.corpuses.models import Corpus
234+
from opencontractserver.corpuses.services.corpus_service import CorpusService
235+
from opencontractserver.shared.services.conventions import ServiceResult
236+
237+
try:
238+
fresh = Corpus.objects.get(pk=corpus_pk)
239+
except Corpus.DoesNotExist:
240+
return ServiceResult.failure(
241+
"Corpus no longer exists; the regenerated icon was not saved."
242+
)
243+
244+
acting_user = get_user_model().objects.filter(pk=user_pk).first()
245+
if acting_user is None:
246+
return ServiceResult.failure(
247+
"Acting user no longer exists; the regenerated icon was not saved."
248+
)
249+
250+
# ``update_icon`` is the authoritative creator-only gate, so this helper
251+
# stays safe even if a future caller skips an up-front check.
252+
return CorpusService.update_icon(
253+
acting_user, fresh, image_bytes=image_bytes, extension=ext
254+
)
255+
256+
return await _save()
257+
258+
179259
# --------------------------------------------------------------------------- #
180260
# Prompt builders
181261
# --------------------------------------------------------------------------- #
@@ -255,16 +335,49 @@ def _build_branding_system_prompt(corpus: Corpus, tools: list[str]) -> str:
255335
return "\n".join(parts)
256336

257337

258-
def _build_logo_prompt(corpus: Corpus) -> str:
338+
def sanitize_logo_instruction_hint(additional_instructions: str | None) -> str:
339+
"""Return the sanitised styling hint, or ``""`` if it adds nothing.
340+
341+
Shared by :func:`_build_logo_prompt` (which only appends the hint when this
342+
is non-empty) and the ``regenerate_corpus_icon`` tool (which reports
343+
``additional_instructions_applied`` from it) so the "was a hint applied?"
344+
answer is derived from the *sanitised* value in one place. A value that is
345+
blank or consists only of stripped characters (e.g. quotes) collapses to
346+
``""`` here, so it never falsely reports as applied.
347+
"""
348+
if not (additional_instructions and additional_instructions.strip()):
349+
return ""
350+
from opencontractserver.constants.corpus_branding import (
351+
CORPUS_LOGO_ADDITIONAL_INSTRUCTIONS_MAX_CHARS,
352+
)
353+
from opencontractserver.utils.prompt_sanitization import (
354+
sanitize_plaintext_for_prompt,
355+
)
356+
357+
return sanitize_plaintext_for_prompt(
358+
additional_instructions.strip(),
359+
max_length=CORPUS_LOGO_ADDITIONAL_INSTRUCTIONS_MAX_CHARS,
360+
)
361+
362+
363+
def _build_logo_prompt(
364+
corpus: Corpus, additional_instructions: str | None = None
365+
) -> str:
259366
"""Text-to-image prompt for the corpus logo.
260367
261-
SECURITY: the title/description are user-controlled and are interpolated
262-
directly into the (quoted) image prompt. A text-to-image model has no
263-
``<user_content>`` fence concept, so we instead neutralise the values with
264-
``sanitize_plaintext_for_prompt`` — stripping quotes and collapsing
265-
whitespace — so a crafted title cannot break out of the quotes and inject
266-
its own directives (e.g. ``" . Instead, render the text: ...``). This
267-
mirrors the prompt-hardening applied to the README agent's system prompt.
368+
``additional_instructions`` is an optional free-text styling hint supplied
369+
by the manual ``regenerate_corpus_icon`` agent tool (the auto-branding path
370+
passes ``None``). When present it is folded into the prompt so a user can
371+
steer the look (e.g. "use blue tones and a gavel motif").
372+
373+
SECURITY: the title/description AND the styling hint are user-controlled and
374+
are interpolated directly into the (quoted) image prompt. A text-to-image
375+
model has no ``<user_content>`` fence concept, so we instead neutralise the
376+
values with ``sanitize_plaintext_for_prompt`` — stripping quotes and
377+
collapsing whitespace — so a crafted value cannot break out of the quotes
378+
and inject its own directives (e.g. ``" . Instead, render the text: ...``).
379+
This mirrors the prompt-hardening applied to the README agent's system
380+
prompt.
268381
"""
269382
from opencontractserver.utils.prompt_sanitization import (
270383
sanitize_plaintext_for_prompt,
@@ -283,6 +396,9 @@ def _build_logo_prompt(corpus: Corpus) -> str:
283396
)
284397
if description:
285398
prompt += f" The collection is about: {description}."
399+
hint = sanitize_logo_instruction_hint(additional_instructions)
400+
if hint:
401+
prompt += f" Additional style guidance: {hint}."
286402
prompt += (
287403
" Flat design, simple geometric shapes, a single focal symbol, bold "
288404
"solid colors, centered on a plain background, no text, no words, no "

opencontractserver/llms/agents/pydantic_ai_agents.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3413,6 +3413,19 @@ class DocAnswer(BaseModel):
34133413
if config.user_id is not None:
34143414
effective_tools.append(update_corpus_desc_tool_wrapped)
34153415

3416+
# Let the creator regenerate the corpus icon on demand — the same
3417+
# generator the auto-branding flow runs at creation time. Built from
3418+
# the registry so corpus_id/user_id are injected and the approval
3419+
# gate + creator-only write are honoured. Sibling of
3420+
# update_corpus_description (the other creator-only corpus-row write).
3421+
effective_tools.extend(
3422+
_build_tools_from_registry(
3423+
["regenerate_corpus_icon"],
3424+
corpus_id=context.corpus.id,
3425+
user_id=config.user_id,
3426+
)
3427+
)
3428+
34163429
# Deep-research tools: let the chat agent spawn a long-running,
34173430
# autonomous research job over this corpus (``start_deep_research``)
34183431
# and report on its progress (``check_deep_research_status``) so a

opencontractserver/llms/tools/core_tools/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
apropose_caml_citation_match,
3737
aread_corpus_caml_article,
3838
)
39+
from .corpus_branding import aregenerate_corpus_icon # noqa: F401
3940
from .descriptions import ( # noqa: F401
4041
aget_corpus_description,
4142
aget_document_description,
@@ -188,6 +189,8 @@
188189
"aapply_caml_article_edit",
189190
"apropose_caml_citation_match",
190191
"aread_corpus_caml_article",
192+
# Corpus icon (logo) regeneration
193+
"aregenerate_corpus_icon",
191194
# Document indexing
192195
"IndexEntryItem",
193196
"acreate_document_index",
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Agent tool for manually (re)generating a corpus's icon/logo.
2+
3+
The corpus auto-branding flow
4+
(``opencontractserver/corpuses/services/branding.py``) generates a logo once,
5+
at corpus-creation time, when no icon was uploaded. This tool exposes the
6+
*same* generator to an agent so a user can trigger it on demand — e.g.
7+
"regenerate the icon for this collection" or "give it a logo featuring a set of
8+
scales" — at any point in a corpus conversation.
9+
10+
Permissioning mirrors the rest of the corpus-row write surface:
11+
12+
* The corpus must be visible to the calling user (IDOR-safe error otherwise).
13+
* Writing the icon is **creator-only**, exactly like
14+
:meth:`CorpusService.update_icon` / ``update_corpus_description`` — even a
15+
collaborator with a guardian UPDATE grant cannot replace it. The creator gate
16+
is checked up front (so we never spend an image-generation round-trip on a
17+
request that cannot succeed) and is re-enforced by ``update_icon`` itself.
18+
19+
The tool is registered ``requires_approval=True`` so each regeneration surfaces
20+
a confirmation prompt before it overwrites the existing icon.
21+
22+
``corpus_id`` and ``user_id`` are framework-injected (hidden from the LLM) by
23+
``build_inject_params_for_context``; the only LLM-visible argument is the
24+
optional ``additional_instructions`` styling hint.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import logging
30+
from typing import Any
31+
32+
from django.contrib.auth import get_user_model
33+
34+
from opencontractserver.corpuses.models import Corpus
35+
36+
from ._helpers import _db_sync_to_async
37+
38+
logger = logging.getLogger(__name__)
39+
40+
User = get_user_model()
41+
42+
43+
def _authorize_icon_regeneration(
44+
corpus_id: int, user_id: int | None
45+
) -> tuple[Corpus, Any]:
46+
"""Resolve and authorize ``(corpus, user)`` for an icon regeneration.
47+
48+
The user element of the return is typed ``Any``: ``User`` here is the
49+
``get_user_model()`` runtime variable (not a type alias), so a quoted
50+
annotation would trip mypy's ``valid-type`` check (mirrors
51+
``extracts_and_analyzers._get_user_or_none``).
52+
53+
Raises ``PermissionError`` — a security exception the tool wrapper
54+
propagates rather than swallowing — when the user is anonymous, missing,
55+
cannot see the corpus, or is not the corpus creator. The "cannot access"
56+
branch returns the same opaque framing the other corpus tools use so the
57+
message cannot be used to enumerate corpora.
58+
"""
59+
if user_id is None:
60+
raise PermissionError("regenerate_corpus_icon requires an authenticated user.")
61+
62+
user = User.objects.filter(pk=user_id).first()
63+
if user is None:
64+
raise PermissionError(f"User {user_id} not found.")
65+
66+
corpus = Corpus.objects.visible_to_user(user).filter(pk=corpus_id).first()
67+
if corpus is None:
68+
raise PermissionError(f"User {user_id} cannot access corpus {corpus_id}.")
69+
70+
# Creator-only: mirror ``CorpusService.update_icon``'s authoritative gate so
71+
# we fail fast (before generating an image) on a request that cannot persist.
72+
if corpus.creator_id != user.id:
73+
raise PermissionError(
74+
f"Only the corpus creator can regenerate the icon for corpus {corpus_id}."
75+
)
76+
return corpus, user
77+
78+
79+
async def aregenerate_corpus_icon(
80+
*,
81+
corpus_id: int,
82+
user_id: int | None = None,
83+
additional_instructions: str | None = None,
84+
) -> dict[str, Any]:
85+
"""Generate a fresh icon/logo for the corpus and save it.
86+
87+
Re-runs the corpus logo generator (OpenAI Images with a deterministic PIL
88+
monogram fallback) and writes the result to the corpus's ``icon``,
89+
**replacing** any existing icon. Useful when the creator wants a new look,
90+
or wants to steer the style via ``additional_instructions``.
91+
92+
Args:
93+
corpus_id: Corpus whose icon to regenerate (injected from context).
94+
user_id: User performing the regeneration (injected from context);
95+
must be the corpus creator.
96+
additional_instructions: Optional free-text styling hint folded into
97+
the image prompt (e.g. "use blue tones and a gavel motif"). Only
98+
affects AI generation; the deterministic monogram fallback (used
99+
when image generation is disabled or unconfigured) ignores it.
100+
Sanitised and length-capped before use.
101+
102+
Returns:
103+
A small status dict describing the result.
104+
"""
105+
corpus, user = await _db_sync_to_async(_authorize_icon_regeneration)(
106+
corpus_id, user_id
107+
)
108+
109+
# Deferred import: the branding service imports from the corpuses package at
110+
# module load, so importing it at the top of a tool module that is itself
111+
# imported during tool-registry population risks a cycle.
112+
from opencontractserver.corpuses.services.branding import (
113+
aregenerate_corpus_logo,
114+
sanitize_logo_instruction_hint,
115+
)
116+
117+
result = await aregenerate_corpus_logo(
118+
corpus, user, additional_instructions=additional_instructions
119+
)
120+
if not result.ok:
121+
# The up-front gate already screens the creator-only case, so reaching
122+
# here means the corpus/user state changed mid-flight (e.g. a hard
123+
# delete during the slow image generation). Surface it as an operational
124+
# error string so the agent can inform the user rather than crashing the
125+
# turn.
126+
raise ValueError(result.error or "Failed to save the regenerated corpus icon.")
127+
128+
return {
129+
"corpus_id": corpus.id,
130+
"status": "updated",
131+
# Report from the *sanitised* hint (the same value the prompt builder
132+
# appends) so a hint that collapses to empty (e.g. only quotes) is not
133+
# falsely reported as applied.
134+
"additional_instructions_applied": bool(
135+
sanitize_logo_instruction_hint(additional_instructions)
136+
),
137+
"detail": "A new corpus icon was generated and saved.",
138+
}

0 commit comments

Comments
 (0)