2727if 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
3133logger = 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 "
0 commit comments