From a9ac989f8274556496f80cfc3911e050b21b59e0 Mon Sep 17 00:00:00 2001 From: sandeshit Date: Mon, 29 Jun 2026 16:02:14 +0545 Subject: [PATCH 1/4] feat(dref): retrigger from admin panel and latest source doc. - Retrigger from the admin panel. - Store the source doc type used for summary generation --- dref/admin.py | 25 +++++++++++++++++-- ...source_id_drefsummary_source_model_name.py | 23 +++++++++++++++++ dref/models.py | 17 ++++++++++++- 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py diff --git a/dref/admin.py b/dref/admin.py index 8ff4f67ce..0cb0af2cd 100644 --- a/dref/admin.py +++ b/dref/admin.py @@ -1,6 +1,7 @@ -from django.contrib import admin +from django.contrib import admin, messages from reversion_compare.admin import CompareVersionAdmin +from api.utils import get_model_name from lang.admin import TranslationAdmin, TranslationInlineModelAdmin from .models import ( @@ -16,6 +17,7 @@ RiskSecurity, SourceInformation, ) +from .tasks import generate_dref_summary class ReadOnlyMixin: @@ -329,11 +331,12 @@ class ProposedActionAdmin(ReadOnlyMixin, admin.ModelAdmin): @admin.register(DrefSummary) class DrefSummaryAdmin(TranslationAdmin, admin.ModelAdmin): - list_display = ("dref", "status", "created_at", "updated_at") + list_display = ("dref", "status", "source_model_name", "source_id", "created_at", "updated_at") list_filter = ("status",) search_fields = ("dref__title", "dref__appeal_code") readonly_fields = ("prompt_hash", "created_at", "updated_at") autocomplete_fields = ("dref",) + actions = ["regenerate_summary"] fields = ( "dref", "status", @@ -346,3 +349,21 @@ class DrefSummaryAdmin(TranslationAdmin, admin.ModelAdmin): "created_at", "updated_at", ) + + @admin.action(description="Regenerate summary for selected DREFs") + def regenerate_summary(self, request, queryset): + """Re-trigger summary generation, replaying the source the summary was last built from.""" + for summary in queryset: + generate_dref_summary.delay( + summary.dref_id, + # Fall back to the Dref itself for rows generated before the + # source was tracked. + source_model_name=summary.source_model_name or get_model_name(Dref), + source_id=summary.source_id or summary.dref_id, + overwrite=True, + ) + self.message_user( + request, + f"Queued summary regeneration for {queryset.count()} DREF(s).", + messages.SUCCESS, + ) diff --git a/dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py b/dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py new file mode 100644 index 000000000..3ebd249e2 --- /dev/null +++ b/dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-06-29 08:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dref', '0091_drefsummary_challenges_identified_ar_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='drefsummary', + name='source_id', + field=models.PositiveBigIntegerField(blank=True, null=True, verbose_name='source id'), + ), + migrations.AddField( + model_name='drefsummary', + name='source_model_name', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='source model name'), + ), + ] diff --git a/dref/models.py b/dref/models.py index 8d2f9280a..c52787538 100644 --- a/dref/models.py +++ b/dref/models.py @@ -1719,7 +1719,22 @@ class SummaryStatus(models.IntegerChoices): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - prompt_hash = models.CharField( + # The source document the summary was last generated from. Stored so a + # retrigger (e.g. from the admin) can replay the same source instead of + # re-resolving the latest approved document. + source_model_name = models.CharField( + max_length=255, + verbose_name=_("source model name"), + blank=True, + null=True, + ) + source_id = models.PositiveBigIntegerField( + verbose_name=_("source id"), + blank=True, + null=True, + ) + + hash = models.CharField( max_length=64, unique=True, ) From 5306b8bbe6ecd33b5123e8dffb30814f7d0d22de Mon Sep 17 00:00:00 2001 From: sandeshit Date: Mon, 29 Jun 2026 16:03:18 +0545 Subject: [PATCH 2/4] feat(dref-summary): summary generation logic and scaffolding. - Summary generation logic. - Task for summary generation. - Triggers in views --- dref/summary.py | 391 ++++++++++++++++++++++++++++++++++++++++++++++++ dref/tasks.py | 68 +++++++++ dref/views.py | 23 ++- 3 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 dref/summary.py diff --git a/dref/summary.py b/dref/summary.py new file mode 100644 index 000000000..493810232 --- /dev/null +++ b/dref/summary.py @@ -0,0 +1,391 @@ +""" +DREF AI summary generation. +""" + +import hashlib +import json +import logging +from typing import Callable, Dict, List, Optional + +import tiktoken +from django.conf import settings +from django.utils.functional import cached_property +from openai import AzureOpenAI + +from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate + +logger = logging.getLogger(__name__) + +ENCODING_NAME = "cl100k_base" + +MAX_OUTPUT_CHARS_PER_FIELD = 1500 +MAX_INPUT_TOKENS = 10000 + +# DrefSummary fields — order is the iteration order for prompt assembly. +SUMMARY_FIELDS: List[str] = [ + "situational_overview", + "operational_strategy", + "people_centered_approach", + "challenges_identified", + "lessons_learned", +] + +SYSTEM_MESSAGE = ( + "You are an IFRC expert analyst specializing in DREF (Disaster Response Emergency Fund) " + "operations. Analyze the provided DREF data and produce clear, professional humanitarian " + "summaries suitable for IFRC staff and National Society personnel. Use only the information " + "provided in the data; do not invent facts, figures, or details. Where supporting information " + "for a section is absent, return an empty string for that section rather than speculating." +) + +# Section prompt builders + + +def _build_situational_overview_prompt(**kwargs) -> str: + data = {k: v for k, v in kwargs.items() if v not in (None, "", [], {})} + data_json = json.dumps(data, indent=2, ensure_ascii=False, default=str) + return ( + 'Data for "situational_overview" — disaster context, affected areas, ' + f"scale of impact and strategic rationale for the operation:\n{data_json}" + ) + + +def _build_operational_strategy_prompt(**kwargs) -> str: + data = {k: v for k, v in kwargs.items() if v not in (None, "", [], {})} + data_json = json.dumps(data, indent=2, ensure_ascii=False, default=str) + return ( + 'Data for "operational_strategy" — operation objective, response strategy, ' + f"target population, timeframe and budget:\n{data_json}" + ) + + +def _build_people_centered_approach_prompt(**kwargs) -> str: + data = {k: v for k, v in kwargs.items() if v not in (None, "", [], {})} + data_json = json.dumps(data, indent=2, ensure_ascii=False, default=str) + return ( + 'Data for "people_centered_approach" — targeting criteria, beneficiary selection, ' + f"engagement and protection approach:\n{data_json}" + ) + + +def _build_challenges_identified_prompt(**kwargs) -> str: + data = {k: v for k, v in kwargs.items() if v not in (None, "", [], {})} + data_json = json.dumps(data, indent=2, ensure_ascii=False, default=str) + return ( + 'Data for "challenges_identified" — operational challenges, gaps, coordination ' + f"issues and security/safety risks recorded per planned intervention:\n{data_json}" + ) + + +def _build_lessons_learned_prompt(**kwargs) -> str: + data = {k: v for k, v in kwargs.items() if v not in (None, "", [], {})} + data_json = json.dumps(data, indent=2, ensure_ascii=False, default=str) + return ( + 'Data for "lessons_learned" — lessons learnt recorded per planned intervention ' f"(empty if none recorded):\n{data_json}" + ) + + +# Registry +SECTION_PROMPT_BUILDERS: Dict[str, Callable[..., str]] = { + "situational_overview": _build_situational_overview_prompt, + "operational_strategy": _build_operational_strategy_prompt, + "people_centered_approach": _build_people_centered_approach_prompt, + "challenges_identified": _build_challenges_identified_prompt, + "lessons_learned": _build_lessons_learned_prompt, +} + +GLOBAL_PROMPT = ( + "The DREF data above is organised by summary section. Using ONLY that data, write five concise " + "summary sections. Return a single JSON object (and nothing else) with exactly these keys:\n" + "\n" + ' "situational_overview": The disaster situation, affected areas and scale of impact, plus the ' + 'strategic rationale for the operation. Use the data under the "situational_overview" key. ' + "Write a coherent paragraph; include specific figures where available (e.g. people affected, displaced).\n" + ' "operational_strategy": The overall objective and strategic approach of the response. Use the ' + 'data under the "operational_strategy" key. State the target population, timeframe and headline ' + "interventions where available.\n" + ' "people_centered_approach": Who is targeted and how they are selected, engaged and protected. ' + 'Use the data under the "people_centered_approach" key, including the women/men/girls/boys ' + "breakdown where given.\n" + ' "challenges_identified": The challenges, gaps, coordination issues and security/safety risks. ' + 'Use the data under the "challenges_identified" key.\n' + ' "lessons_learned": The lessons learned from this operation. Use the data under the ' + '"lessons_learned" key. If none are recorded, return an empty string.\n' + "\n" + "Requirements:\n" + "- Each value must be plain text (no markdown, no bullet lists, no nested JSON).\n" + "- Each section should be one well-structured paragraph in professional humanitarian language.\n" + "- Be precise with any facts or numbers; never fabricate them.\n" + "- Return ONLY the JSON object, with no surrounding prose or code fences." +) + + +class DrefSummaryLLMClient: + @cached_property + def client(self): + return AzureOpenAI( + azure_endpoint=settings.AZURE_OPENAI_ENDPOINT, + api_key=settings.AZURE_OPENAI_KEY, + api_version="2023-05-15", + ) + + def get_response(self, messages) -> Optional[str]: + try: + response = self.client.chat.completions.create( + model=settings.AZURE_OPENAI_DEPLOYMENT_NAME, + messages=messages, + temperature=0.5, + ) + return response.choices[0].message.content + except Exception as e: + logger.error(f"Error while generating DREF summary response: {e}", exc_info=True) + return None + + +def count_tokens(text: str) -> int: + try: + return len(tiktoken.get_encoding(ENCODING_NAME).encode(text)) + except Exception as e: + logger.error(f"Error counting tokens for DREF summary: {e}") + return len(text) // 4 + + +def _field_val(obj, field_name): + """Return a model field's value, preferring human-readable display for choice fields.""" + v = getattr(obj, field_name, None) + if v in (None, ""): + return None + display = getattr(obj, f"get_{field_name}_display", None) + return display() if callable(display) else v + + +def _extract_fields(obj, field_names: List[str]) -> dict: + return {name: v for name in field_names if (v := _field_val(obj, name)) is not None} + + +# Shared field lists. + +SITUATIONAL_COMMON_FIELDS: List[str] = ["title", "event_description", "date_of_approval"] + +OPERATIONAL_COMMON_FIELDS: List[str] = [ + "operation_objective", + "response_strategy", + "total_targeted_population", + "people_in_need", + "event_date", +] + +PEOPLE_COMMON_FIELDS: List[str] = ["people_assisted", "selection_criteria"] + +DEMOGRAPHIC_FIELDS: List[str] = ["women", "men", "girls", "boys"] + + +class DrefSummaryGenerator: + """Assembles per-section prompts from a source document and produces all summaries.""" + + def __init__(self, client: Optional[DrefSummaryLLMClient] = None): + self.client = client or DrefSummaryLLMClient() + + @classmethod + def _model_label(cls, source_doc) -> str: + return f"{source_doc._meta.app_label}.{type(source_doc).__name__}" + + # Shared helpers — called by multiple extractors + + @staticmethod + def _situational_overview_kwargs(source_doc) -> dict: + """Build situational_overview kwargs — common across all document types. + + ``event_scope`` is omitted for Imminent DREFs since the scope is not + yet known at that stage. + """ + is_imminent = source_doc.type_of_dref == Dref.DrefType.IMMINENT + kwargs = _extract_fields(source_doc, SITUATIONAL_COMMON_FIELDS) + if source_doc.country: + kwargs["country"] = {"name": source_doc.country.name, "iso": source_doc.country.iso} + if source_doc.disaster_type: + kwargs["disaster_type"] = {"name": source_doc.disaster_type.name} + # event_scope is not yet known for Imminent DREFs, so it is excluded there. + if not is_imminent: + kwargs.update(_extract_fields(source_doc, ["event_scope"])) + return kwargs + + @staticmethod + def _challenges_and_lessons_kwargs(source_doc) -> Dict[str, dict]: + """Collect challenges and lessons from ``planned_interventions`` M2M. + + Only called for ``DrefFinalReport`` + """ + + def pi_title(pi): + display = getattr(pi, "get_title_display", None) + return display() if callable(display) else pi.title + + # Order explicitly: planned_interventions has no Meta.ordering, so an + # unordered .all() can return rows in different orders across queries, + # which would change the source hash and trigger needless regeneration. + planned = list(source_doc.planned_interventions.order_by("id")) + return { + "challenges_identified": { + "planned_interventions": [{"title": pi_title(pi), "challenges": pi.challenges} for pi in planned if pi.challenges] + or None, + }, + "lessons_learned": { + "planned_interventions": [ + {"title": pi_title(pi), "lessons_learnt": pi.lessons_learnt} for pi in planned if pi.lessons_learnt + ] + or None, + }, + } + + @classmethod + def _extract_dref_kwargs(cls, dref) -> Dict[str, dict]: + """Dref Application / Assessment / Imminent — three sections only. + + Challenges and lessons are not applicable at the application stage; + they are formally recorded only in the Final Report. + """ + is_imminent = dref.type_of_dref == Dref.DrefType.IMMINENT + operational = _extract_fields(dref, OPERATIONAL_COMMON_FIELDS + ["amount_requested", "end_date"]) + # Normalise to a single "operation_timeframe" key regardless of source field. + timeframe = _field_val(dref, "operation_timeframe_imminent" if is_imminent else "operation_timeframe") + if timeframe is not None: + operational["operation_timeframe"] = timeframe + return { + "situational_overview": cls._situational_overview_kwargs(dref), + "operational_strategy": operational, + "people_centered_approach": _extract_fields(dref, PEOPLE_COMMON_FIELDS), + } + + @classmethod + def _extract_dref_ops_kwargs(cls, ops) -> Dict[str, dict]: + """DrefOperationalUpdate — three sections; different field names from Dref. + + Includes demographic breakdown (women/men/girls/boys) in people_centered_approach. + Challenges and lessons are not generated for Operational Updates. + """ + operational = _extract_fields(ops, OPERATIONAL_COMMON_FIELDS + ["total_dref_allocation"]) + # Ops Update names these differently; normalise to the shared key names. + operational.update( + { + k: v + for k, v in { + "operation_end_date": _field_val(ops, "new_operational_end_date"), + "operation_timeframe": _field_val(ops, "total_operation_timeframe"), + }.items() + if v is not None + } + ) + return { + "situational_overview": cls._situational_overview_kwargs(ops), + "operational_strategy": operational, + "people_centered_approach": _extract_fields(ops, PEOPLE_COMMON_FIELDS + DEMOGRAPHIC_FIELDS), + } + + @classmethod + def _extract_dref_final_kwargs(cls, final) -> Dict[str, dict]: + """DrefFinalReport — all five sections. + + Challenges and lessons come from ``planned_interventions`` M2M via + ``_challenges_and_lessons_kwargs``; this is the only document type + where those two sections are generated. + """ + is_imminent = final.type_of_dref == Dref.DrefType.IMMINENT + operational = _extract_fields(final, OPERATIONAL_COMMON_FIELDS + ["total_dref_allocation", "operation_end_date"]) + # Normalise to a single "operation_timeframe" key regardless of source field. + timeframe = _field_val(final, "total_operation_timeframe_imminent" if is_imminent else "total_operation_timeframe") + if timeframe is not None: + operational["operation_timeframe"] = timeframe + kwargs = { + "situational_overview": cls._situational_overview_kwargs(final), + "operational_strategy": operational, + "people_centered_approach": _extract_fields(final, PEOPLE_COMMON_FIELDS + DEMOGRAPHIC_FIELDS), + } + kwargs.update(cls._challenges_and_lessons_kwargs(final)) + return kwargs + + @classmethod + def _get_section_kwargs(cls, source_doc) -> Dict[str, dict]: + """Dispatch to the model-specific extractor.""" + if isinstance(source_doc, Dref): + return cls._extract_dref_kwargs(source_doc) + if isinstance(source_doc, DrefOperationalUpdate): + return cls._extract_dref_ops_kwargs(source_doc) + if isinstance(source_doc, DrefFinalReport): + return cls._extract_dref_final_kwargs(source_doc) + return {} + + @classmethod + def build_source_text(cls, source_doc) -> str: + """JSON representation of all section kwargs for inspection and debugging.""" + return json.dumps(cls._get_section_kwargs(source_doc), indent=2, ensure_ascii=False, default=str) + + @classmethod + def compute_source_hash(cls, source_doc) -> str: + """Hash of all source content feeding the summary, for change detection.""" + model_label = cls._model_label(source_doc) + payload = {"model": model_label, "id": source_doc.id, "source": cls._get_section_kwargs(source_doc)} + content = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + @staticmethod + def _parse_response(response: str) -> Dict[str, str]: + """Parse the model's JSON object, tolerating ```json code fences.""" + text = response.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] if "\n" in text else text + text = text.rsplit("```", 1)[0] + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError("Expected a JSON object of summary fields") + return data + + def generate_all(self, source_doc) -> Dict[str, str]: + """Generate every summary for ``source_doc`` in a single LLM call. + + Always returns a value for every ``SUMMARY_FIELDS`` key (empty string + when a section has no content), so callers overwrite stale values + instead of leaving them in place on regeneration. + """ + empty_results: Dict[str, str] = {field_name: "" for field_name in SUMMARY_FIELDS} + + section_kwargs = self._get_section_kwargs(source_doc) + if not section_kwargs: + logger.info(f"No source content for ({type(source_doc).__name__}) ({source_doc.id}) summary; skipping generation.") + return empty_results + + # Call each builder with only its section's kwargs, then join blocks. + section_blocks = [SECTION_PROMPT_BUILDERS[section](**section_kwargs.get(section, {})) for section in SUMMARY_FIELDS] + user_content = "\n\n".join(section_blocks) + "\n\n" + GLOBAL_PROMPT + + # Respect the token budget: trim the data blocks if the prompt is too long. + if count_tokens(SYSTEM_MESSAGE + user_content) > MAX_INPUT_TOKENS: + logger.warning(f"({type(source_doc).__name__}) ({source_doc.id}) summary prompt too long; truncating source text.") + char_budget = MAX_INPUT_TOKENS * 4 - len(GLOBAL_PROMPT) - len(SYSTEM_MESSAGE) + data_blocks = "\n\n".join(section_blocks) + user_content = data_blocks[: max(char_budget, 0)] + "\n\n" + GLOBAL_PROMPT + + messages = [ + {"role": "system", "content": SYSTEM_MESSAGE}, + {"role": "user", "content": user_content}, + ] + + response = self.client.get_response(messages) + if not response: + raise RuntimeError(f"No LLM response for ({type(source_doc).__name__}) ({source_doc.id}) summary") + + try: + parsed = self._parse_response(response) + except (json.JSONDecodeError, ValueError) as e: + raise RuntimeError(f"Could not parse ({type(source_doc).__name__}) ({source_doc.id}) summary response: {e}") from e + + results = dict(empty_results) + for field_name in SUMMARY_FIELDS: + value = parsed.get(field_name) + if not isinstance(value, str): + continue + summary = value.strip() + if len(summary) > MAX_OUTPUT_CHARS_PER_FIELD: + summary = summary[:MAX_OUTPUT_CHARS_PER_FIELD].rstrip() + results[field_name] = summary + return results diff --git a/dref/tasks.py b/dref/tasks.py index 0b0d08c96..63ade5f3a 100644 --- a/dref/tasks.py +++ b/dref/tasks.py @@ -12,6 +12,7 @@ from .models import ( Dref, DrefFile, + DrefSummary, IdentifiedNeed, NationalSocietyAction, PlannedIntervention, @@ -21,6 +22,7 @@ RiskSecurity, SourceInformation, ) +from .summary import DrefSummaryGenerator from .utils import get_email_context logger = logging.getLogger(__name__) @@ -57,6 +59,72 @@ def send_dref_email(dref_id, users_emails, new_or_updated=""): ] +@shared_task +def generate_dref_summary(dref_id, source_model_name=None, source_id=None, overwrite=False): + """Generate and store the AI-assisted summaries for a DREF.""" + dref = Dref.objects.filter(id=dref_id).first() + if not dref: + logger.error(f"DREF not found for summary generation: ({dref_id})") + return False + + if source_model_name and source_id: + try: + source_model = apps.get_model(source_model_name) + source_doc = source_model.objects.filter(id=source_id).first() + except Exception: + logger.error( + f"Could not resolve source model ({source_model_name}) for DREF ({dref_id}) summary", + exc_info=True, + ) + return False + if not source_doc: + logger.error(f"Source document ({source_model_name}) ({source_id}) not found for DREF ({dref_id}) summary") + return False + else: + source_doc = dref + + # Normalise the source identity so it is always stored, even when the task + # is called with just a dref_id (source_doc is the Dref itself). + source_model_name = get_model_name(type(source_doc)) + source_id = source_doc.id + + generator = DrefSummaryGenerator() + source_hash = generator.compute_source_hash(source_doc) + + summary_instance = DrefSummary.objects.filter(dref=dref).first() + if ( + summary_instance + and not overwrite + and summary_instance.hash == source_hash + and summary_instance.status == DrefSummary.SummaryStatus.SUCCESS + ): + logger.info(f"DREF summary up to date for DREF ({dref_id}); skipping generation.") + return True + + if summary_instance is None: + summary_instance = DrefSummary(dref=dref) + summary_instance.hash = source_hash + summary_instance.source_model_name = source_model_name + summary_instance.source_id = source_id + + try: + logger.info( + f"Generating DREF summaries for DREF ({dref_id}) from ({source_model_name or 'dref.Dref'}) ({source_id or dref_id})" + ) + results = generator.generate_all(source_doc) + for field_name, value in results.items(): + setattr(summary_instance, field_name, value) + summary_instance.status = DrefSummary.SummaryStatus.SUCCESS + summary_instance.save() + logger.info(f"Successfully generated DREF summaries for DREF ({dref_id})") + return True + except Exception: + summary_instance.status = DrefSummary.SummaryStatus.FAILED + summary_instance.save() + logger.warning(f"DREF summary generation failed for DREF ({dref_id})", exc_info=True) + return False + + @shared_task def process_dref_translation(model_name, instance_pk): """ diff --git a/dref/views.py b/dref/views.py index 2633f9798..799cfc456 100644 --- a/dref/views.py +++ b/dref/views.py @@ -50,7 +50,7 @@ DrefShareUserSerializer, MiniDrefSerializer, ) -from dref.tasks import process_dref_translation +from dref.tasks import generate_dref_summary, process_dref_translation from dref.utils import create_event_from_dref from lang.serializers import TranslatedModelSerializerMixin from main.permissions import DenyGuestUserPermission @@ -137,6 +137,7 @@ def get_approved(self, request, pk=None, version=None): dref.status = Dref.Status.APPROVED dref.save(update_fields=["event", "status"]) + transaction.on_commit(lambda: generate_dref_summary.delay(dref.id)) return response.Response(DrefSerializer(dref, context={"request": request}).data) @@ -234,6 +235,16 @@ def get_approved(self, request, pk=None, version=None): operational_update.status = Dref.Status.APPROVED operational_update.date_of_approval = timezone.now().date() operational_update.save(update_fields=["status", "date_of_approval"]) + _ops_update_id = operational_update.pk + _dref_id = operational_update.dref_id + transaction.on_commit( + lambda: generate_dref_summary.delay( + _dref_id, + source_model_name=get_model_name(DrefOperationalUpdate), + source_id=_ops_update_id, + overwrite=True, + ) + ) serializer = DrefOperationalUpdateSerializer(operational_update, context={"request": request}) return response.Response(serializer.data) @@ -302,6 +313,16 @@ def get_approved(self, request, pk=None, version=None): final_report.dref.is_active = False final_report.date_of_approval = timezone.now().date() final_report.dref.save(update_fields=["is_active", "date_of_approval"]) + _final_report_id = final_report.pk + _dref_id = final_report.dref_id + transaction.on_commit( + lambda: generate_dref_summary.delay( + _dref_id, + source_model_name=get_model_name(DrefFinalReport), + source_id=_final_report_id, + overwrite=True, + ) + ) serializer = DrefFinalReportSerializer(final_report, context={"request": request}) return response.Response(serializer.data) From 597c182fcabd372f89e608bef16de2df20510d6e Mon Sep 17 00:00:00 2001 From: sandeshit Date: Mon, 29 Jun 2026 17:01:10 +0545 Subject: [PATCH 3/4] feat(migrations): merge migrations --- assets | 2 +- dref/admin.py | 12 ++- dref/factories/dref.py | 4 +- dref/migrations/0090_drefsummary.py | 6 +- ...source_id_drefsummary_source_model_name.py | 23 ------ dref/models.py | 15 ++-- dref/serializers.py | 5 ++ dref/summary.py | 7 +- dref/tasks.py | 80 ++++++++++++------- dref/views.py | 21 +++-- 10 files changed, 92 insertions(+), 83 deletions(-) delete mode 100644 dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py diff --git a/assets b/assets index 5fbdb9ac6..03bb6f64a 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 5fbdb9ac6ad83472709f248c4bbe32a70587b577 +Subproject commit 03bb6f64ab4e668fff21b0c6d10b799b53742284 diff --git a/dref/admin.py b/dref/admin.py index 0cb0af2cd..e5849fb98 100644 --- a/dref/admin.py +++ b/dref/admin.py @@ -1,7 +1,6 @@ from django.contrib import admin, messages from reversion_compare.admin import CompareVersionAdmin -from api.utils import get_model_name from lang.admin import TranslationAdmin, TranslationInlineModelAdmin from .models import ( @@ -133,13 +132,13 @@ class DrefSummaryInline(admin.StackedInline, TranslationInlineModelAdmin): extra = 0 readonly_fields = ( "status", - "prompt_hash", + "source_hash", "created_at", "updated_at", ) fields = ( "status", - "prompt_hash", + "source_hash", "situational_overview", "operational_strategy", "people_centered_approach", @@ -334,13 +333,13 @@ class DrefSummaryAdmin(TranslationAdmin, admin.ModelAdmin): list_display = ("dref", "status", "source_model_name", "source_id", "created_at", "updated_at") list_filter = ("status",) search_fields = ("dref__title", "dref__appeal_code") - readonly_fields = ("prompt_hash", "created_at", "updated_at") + readonly_fields = ("source_hash", "created_at", "updated_at") autocomplete_fields = ("dref",) actions = ["regenerate_summary"] fields = ( "dref", "status", - "prompt_hash", + "source_hash", "situational_overview", "operational_strategy", "people_centered_approach", @@ -355,10 +354,9 @@ def regenerate_summary(self, request, queryset): """Re-trigger summary generation, replaying the source the summary was last built from.""" for summary in queryset: generate_dref_summary.delay( - summary.dref_id, # Fall back to the Dref itself for rows generated before the # source was tracked. - source_model_name=summary.source_model_name or get_model_name(Dref), + source_model_name=summary.source_model_name or DrefSummary.SourceModel.DREF, source_id=summary.source_id or summary.dref_id, overwrite=True, ) diff --git a/dref/factories/dref.py b/dref/factories/dref.py index 1ca5efb69..4ebb6d509 100644 --- a/dref/factories/dref.py +++ b/dref/factories/dref.py @@ -206,7 +206,9 @@ class Meta: model = DrefSummary dref = factory.SubFactory(DrefFactory) - prompt_hash = factory.Sequence(lambda n: f"{n:064d}") + source_hash = factory.Sequence(lambda n: f"{n:064d}") + source_model_name = DrefSummary.SourceModel.DREF + source_id = factory.SelfAttribute("dref.id") status = DrefSummary.SummaryStatus.SUCCESS situational_overview = fuzzy.FuzzyText(length=100) operational_strategy = fuzzy.FuzzyText(length=100) diff --git a/dref/migrations/0090_drefsummary.py b/dref/migrations/0090_drefsummary.py index e68b40311..401009b54 100644 --- a/dref/migrations/0090_drefsummary.py +++ b/dref/migrations/0090_drefsummary.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.14 on 2026-06-26 02:06 +# Generated by Django 5.2.14 on 2026-07-03 00:58 import django.db.models.deletion from django.db import migrations, models @@ -17,7 +17,9 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('prompt_hash', models.CharField(max_length=64, unique=True)), + ('source_model_name', models.IntegerField(choices=[(100, 'Dref'), (200, 'Dref Operational Update'), (300, 'Dref Final Report')], verbose_name='source model name')), + ('source_id', models.PositiveBigIntegerField(verbose_name='source id')), + ('source_hash', models.CharField(max_length=64, unique=True)), ('situational_overview', models.TextField(blank=True, null=True)), ('situational_overview_en', models.TextField(blank=True, null=True)), ('situational_overview_es', models.TextField(blank=True, null=True)), diff --git a/dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py b/dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py deleted file mode 100644 index 3ebd249e2..000000000 --- a/dref/migrations/0092_drefsummary_source_id_drefsummary_source_model_name.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.14 on 2026-06-29 08:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dref', '0091_drefsummary_challenges_identified_ar_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='drefsummary', - name='source_id', - field=models.PositiveBigIntegerField(blank=True, null=True, verbose_name='source id'), - ), - migrations.AddField( - model_name='drefsummary', - name='source_model_name', - field=models.CharField(blank=True, max_length=255, null=True, verbose_name='source model name'), - ), - ] diff --git a/dref/models.py b/dref/models.py index c52787538..da4ff5f76 100644 --- a/dref/models.py +++ b/dref/models.py @@ -1710,6 +1710,11 @@ class SummaryStatus(models.IntegerChoices): SUCCESS = 300, _("Success") FAILED = 400, _("Failed") + class SourceModel(models.IntegerChoices): + DREF = 100, _("Dref") + DREF_OPERATIONAL_UPDATE = 200, _("Dref Operational Update") + DREF_FINAL_REPORT = 300, _("Dref Final Report") + dref = models.OneToOneField( Dref, on_delete=models.CASCADE, @@ -1722,19 +1727,15 @@ class SummaryStatus(models.IntegerChoices): # The source document the summary was last generated from. Stored so a # retrigger (e.g. from the admin) can replay the same source instead of # re-resolving the latest approved document. - source_model_name = models.CharField( - max_length=255, + source_model_name = models.IntegerField( verbose_name=_("source model name"), - blank=True, - null=True, + choices=SourceModel.choices, ) source_id = models.PositiveBigIntegerField( verbose_name=_("source id"), - blank=True, - null=True, ) - hash = models.CharField( + source_hash = models.CharField( max_length=64, unique=True, ) diff --git a/dref/serializers.py b/dref/serializers.py index a144ade2e..c8c8bd1ad 100644 --- a/dref/serializers.py +++ b/dref/serializers.py @@ -2429,16 +2429,21 @@ def get_dref_contacts(self, obj): class DrefSummarySerializer(ModelSerializer): + status_display = serializers.CharField(source="get_status_display", read_only=True) + class Meta: model = DrefSummary fields = ( "id", "status", + "status_display", "situational_overview", "operational_strategy", "people_centered_approach", "challenges_identified", "lessons_learned", + "created_at", + "updated_at", ) diff --git a/dref/summary.py b/dref/summary.py index 493810232..013b3f21c 100644 --- a/dref/summary.py +++ b/dref/summary.py @@ -12,6 +12,7 @@ from django.utils.functional import cached_property from openai import AzureOpenAI +from api.utils import get_model_name from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate logger = logging.getLogger(__name__) @@ -186,10 +187,6 @@ class DrefSummaryGenerator: def __init__(self, client: Optional[DrefSummaryLLMClient] = None): self.client = client or DrefSummaryLLMClient() - @classmethod - def _model_label(cls, source_doc) -> str: - return f"{source_doc._meta.app_label}.{type(source_doc).__name__}" - # Shared helpers — called by multiple extractors @staticmethod @@ -323,7 +320,7 @@ def build_source_text(cls, source_doc) -> str: @classmethod def compute_source_hash(cls, source_doc) -> str: """Hash of all source content feeding the summary, for change detection.""" - model_label = cls._model_label(source_doc) + model_label = get_model_name(type(source_doc)) payload = {"model": model_label, "id": source_doc.id, "source": cls._get_section_kwargs(source_doc)} content = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) return hashlib.sha256(content.encode("utf-8")).hexdigest() diff --git a/dref/tasks.py b/dref/tasks.py index 63ade5f3a..c7e0d328d 100644 --- a/dref/tasks.py +++ b/dref/tasks.py @@ -7,11 +7,14 @@ from api.utils import get_model_name from lang.tasks import translate_model_fields from main.translation import TRANSLATOR_ORIGINAL_LANGUAGE_FIELD_NAME +from main.utils import logger_context from notifications.notification import send_notification from .models import ( Dref, DrefFile, + DrefFinalReport, + DrefOperationalUpdate, DrefSummary, IdentifiedNeed, NationalSocietyAction, @@ -59,33 +62,50 @@ def send_dref_email(dref_id, users_emails, new_or_updated=""): ] +def _resolve_source_model(source_model_name): + """Map a ``DrefSummary.SourceModel`` choice to its model class.""" + if source_model_name == DrefSummary.SourceModel.DREF: + return Dref + elif source_model_name == DrefSummary.SourceModel.DREF_OPERATIONAL_UPDATE: + return DrefOperationalUpdate + elif source_model_name == DrefSummary.SourceModel.DREF_FINAL_REPORT: + return DrefFinalReport + return None + + @shared_task -def generate_dref_summary(dref_id, source_model_name=None, source_id=None, overwrite=False): - """Generate and store the AI-assisted summaries for a DREF.""" - dref = Dref.objects.filter(id=dref_id).first() - if not dref: - logger.error(f"DREF not found for summary generation: ({dref_id})") +def generate_dref_summary(source_model_name, source_id, overwrite=False): + """Generate and store the AI-assisted summaries for a DREF. + + The DREF is derived from the source document, so the source (a Dref, + DrefOperationalUpdate or DrefFinalReport) is the single source of truth for + what the summary is built from. + """ + source_model = _resolve_source_model(source_model_name) + if source_model is None: + logger.error( + "Could not resolve source model for DREF summary", + extra=logger_context({"source_model_name": source_model_name, "source_id": source_id}), + ) + return False + source_doc = source_model.objects.filter(id=source_id).first() + if not source_doc: + logger.error( + "Source document not found for DREF summary", + extra=logger_context({"source_model_name": source_model_name, "source_id": source_id}), + ) return False - if source_model_name and source_id: - try: - source_model = apps.get_model(source_model_name) - source_doc = source_model.objects.filter(id=source_id).first() - except Exception: - logger.error( - f"Could not resolve source model ({source_model_name}) for DREF ({dref_id}) summary", - exc_info=True, - ) - return False - if not source_doc: - logger.error(f"Source document ({source_model_name}) ({source_id}) not found for DREF ({dref_id}) summary") - return False - else: - source_doc = dref - - # Normalise the source identity so it is always stored, even when the task - # is called with just a dref_id (source_doc is the Dref itself). - source_model_name = get_model_name(type(source_doc)) + # Derive the DREF from the source: the Dref is its own source, while + # DrefOperationalUpdate and DrefFinalReport both point back via ``dref``. + dref = source_doc if isinstance(source_doc, Dref) else source_doc.dref + if not dref: + logger.error( + "DREF not found for summary generation", + extra=logger_context({"source_model_name": source_model_name, "source_id": source_id}), + ) + return False + dref_id = dref.id source_id = source_doc.id generator = DrefSummaryGenerator() @@ -95,7 +115,7 @@ def generate_dref_summary(dref_id, source_model_name=None, source_id=None, overw if ( summary_instance and not overwrite - and summary_instance.hash == source_hash + and summary_instance.source_hash == source_hash and summary_instance.status == DrefSummary.SummaryStatus.SUCCESS ): logger.info(f"DREF summary up to date for DREF ({dref_id}); skipping generation.") @@ -103,20 +123,22 @@ def generate_dref_summary(dref_id, source_model_name=None, source_id=None, overw if summary_instance is None: summary_instance = DrefSummary(dref=dref) - summary_instance.hash = source_hash + summary_instance.source_hash = source_hash summary_instance.source_model_name = source_model_name summary_instance.source_id = source_id try: - logger.info( - f"Generating DREF summaries for DREF ({dref_id}) from ({source_model_name or 'dref.Dref'}) ({source_id or dref_id})" - ) + logger.info(f"Generating DREF summaries for DREF ({dref_id}) from ({source_model_name}) ({source_id})") results = generator.generate_all(source_doc) for field_name, value in results.items(): setattr(summary_instance, field_name, value) summary_instance.status = DrefSummary.SummaryStatus.SUCCESS + # Generated summaries are always English; set the source language explicitly. + summary_instance.translation_module_original_language = "en" summary_instance.save() logger.info(f"Successfully generated DREF summaries for DREF ({dref_id})") + # Trigger translation eagerly since the summary is not saved through a serializer. + translate_model_fields.delay(get_model_name(DrefSummary), summary_instance.pk) return True except Exception: summary_instance.status = DrefSummary.SummaryStatus.FAILED diff --git a/dref/views.py b/dref/views.py index 799cfc456..480dd7c56 100644 --- a/dref/views.py +++ b/dref/views.py @@ -32,7 +32,13 @@ DrefOperationalUpdateFilter, DrefShareUserFilterSet, ) -from dref.models import Dref, DrefFile, DrefFinalReport, DrefOperationalUpdate +from dref.models import ( + Dref, + DrefFile, + DrefFinalReport, + DrefOperationalUpdate, + DrefSummary, +) from dref.permissions import ApproveDrefPermission from dref.serializers import ( AddDrefUserSerializer, @@ -137,7 +143,10 @@ def get_approved(self, request, pk=None, version=None): dref.status = Dref.Status.APPROVED dref.save(update_fields=["event", "status"]) - transaction.on_commit(lambda: generate_dref_summary.delay(dref.id)) + _dref_id = dref.id + transaction.on_commit( + lambda: generate_dref_summary.delay(source_model_name=DrefSummary.SourceModel.DREF, source_id=_dref_id) + ) return response.Response(DrefSerializer(dref, context={"request": request}).data) @@ -236,11 +245,9 @@ def get_approved(self, request, pk=None, version=None): operational_update.date_of_approval = timezone.now().date() operational_update.save(update_fields=["status", "date_of_approval"]) _ops_update_id = operational_update.pk - _dref_id = operational_update.dref_id transaction.on_commit( lambda: generate_dref_summary.delay( - _dref_id, - source_model_name=get_model_name(DrefOperationalUpdate), + source_model_name=DrefSummary.SourceModel.DREF_OPERATIONAL_UPDATE, source_id=_ops_update_id, overwrite=True, ) @@ -314,11 +321,9 @@ def get_approved(self, request, pk=None, version=None): final_report.date_of_approval = timezone.now().date() final_report.dref.save(update_fields=["is_active", "date_of_approval"]) _final_report_id = final_report.pk - _dref_id = final_report.dref_id transaction.on_commit( lambda: generate_dref_summary.delay( - _dref_id, - source_model_name=get_model_name(DrefFinalReport), + source_model_name=DrefSummary.SourceModel.DREF_FINAL_REPORT, source_id=_final_report_id, overwrite=True, ) From d6e0ff9ac442de25d93f1e10baaa3bd9305f6ae8 Mon Sep 17 00:00:00 2001 From: Sushil Tiwari Date: Fri, 3 Jul 2026 14:27:57 +0545 Subject: [PATCH 4/4] feat(dref): add step for the processing status and use delay translation --- dref/models.py | 4 ++++ dref/tasks.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/dref/models.py b/dref/models.py index da4ff5f76..2715ebaa2 100644 --- a/dref/models.py +++ b/dref/models.py @@ -1233,6 +1233,10 @@ class DrefOperationalUpdate(models.Model): source_information = models.ManyToManyField(SourceInformation, blank=True, verbose_name=_("Source Information")) __budget_file_id = None + # TYPING + id: int + budget_file_id: int | None + class Meta: verbose_name = _("Dref Operational Update") verbose_name_plural = _("Dref Operational Updates") diff --git a/dref/tasks.py b/dref/tasks.py index c7e0d328d..d4c2ae2cd 100644 --- a/dref/tasks.py +++ b/dref/tasks.py @@ -2,6 +2,7 @@ from celery import shared_task from django.apps import apps +from django.db import transaction from django.template.loader import render_to_string from api.utils import get_model_name @@ -126,6 +127,8 @@ def generate_dref_summary(source_model_name, source_id, overwrite=False): summary_instance.source_hash = source_hash summary_instance.source_model_name = source_model_name summary_instance.source_id = source_id + summary_instance.status = DrefSummary.SummaryStatus.PROCESSING + summary_instance.save() try: logger.info(f"Generating DREF summaries for DREF ({dref_id}) from ({source_model_name}) ({source_id})") @@ -133,12 +136,9 @@ def generate_dref_summary(source_model_name, source_id, overwrite=False): for field_name, value in results.items(): setattr(summary_instance, field_name, value) summary_instance.status = DrefSummary.SummaryStatus.SUCCESS - # Generated summaries are always English; set the source language explicitly. - summary_instance.translation_module_original_language = "en" summary_instance.save() logger.info(f"Successfully generated DREF summaries for DREF ({dref_id})") - # Trigger translation eagerly since the summary is not saved through a serializer. - translate_model_fields.delay(get_model_name(DrefSummary), summary_instance.pk) + transaction.on_commit(lambda: translate_model_fields.delay(get_model_name(DrefSummary), summary_instance.pk)) return True except Exception: summary_instance.status = DrefSummary.SummaryStatus.FAILED