Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ x-server: &base_server_setup
CACHE_TEST_REDIS_URL: ${CACHE_TEST_REDIS_URL:-redis://redis:6379/11}
CACHE_MIDDLEWARE_SECONDS: ${CACHE_MIDDLEWARE_SECONDS:-600}
ELASTIC_SEARCH_HOST: ${ELASTIC_SEARCH_HOST:-elasticsearch://elasticsearch:9200}
# LLM
USE_DUMMY_LLM_CLIENT: ${USE_DUMMY_LLM_CLIENT:-true}
# Appeal API
APPEALS_USER: ${APPEALS_USER:-}
APPEALS_PASS: ${APPEALS_PASS:-}
Expand Down
13 changes: 4 additions & 9 deletions dref/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ class ProposedActionAdmin(ReadOnlyMixin, admin.ModelAdmin):

@admin.register(DrefSummary)
class DrefSummaryAdmin(TranslationAdmin, admin.ModelAdmin):
list_display = ("dref", "status", "source_model_name", "source_id", "created_at", "updated_at")
list_display = ("dref", "status", "source", "source_id", "created_at", "updated_at")
list_filter = ("status",)
search_fields = ("dref__title", "dref__appeal_code")
readonly_fields = ("source_hash", "created_at", "updated_at")
Expand All @@ -351,15 +351,10 @@ class DrefSummaryAdmin(TranslationAdmin, admin.ModelAdmin):

@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."""
"""Re-trigger summary generation from the DREF's current latest approved source."""
queryset.filter(status=DrefSummary.SummaryStatus.PROCESSING).update(status=DrefSummary.SummaryStatus.FAILED)
for summary in queryset:
generate_dref_summary.delay(
# Fall back to the Dref itself for rows generated before the
# source was tracked.
source_model_name=summary.source_model_name or DrefSummary.SourceModel.DREF,
source_id=summary.source_id or summary.dref_id,
overwrite=True,
)
generate_dref_summary.delay(dref_id=summary.dref_id, overwrite=True)
self.message_user(
request,
f"Queued summary regeneration for {queryset.count()} DREF(s).",
Expand Down
2 changes: 1 addition & 1 deletion dref/factories/dref.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ class Meta:

dref = factory.SubFactory(DrefFactory)
source_hash = factory.Sequence(lambda n: f"{n:064d}")
source_model_name = DrefSummary.SourceModel.DREF
source = DrefSummary.SourceModel.DREF
source_id = factory.SelfAttribute("dref.id")
status = DrefSummary.SummaryStatus.SUCCESS
situational_overview = fuzzy.FuzzyText(length=100)
Expand Down
2 changes: 1 addition & 1 deletion dref/migrations/0090_drefsummary.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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)),
('source_model_name', models.IntegerField(choices=[(100, 'Dref'), (200, 'Dref Operational Update'), (300, 'Dref Final Report')], verbose_name='source model name')),
('source', models.IntegerField(choices=[(100, 'Dref'), (200, 'Dref Operational Update'), (300, 'Dref Final Report')], help_text='The model this summary was generated from.', verbose_name='source')),
('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)),
Expand Down
11 changes: 6 additions & 5 deletions dref/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,9 @@ class DrefFinalReport(models.Model):
)
__financial_report_id = None

# TYPING
id: int

class Meta:
verbose_name = _("Dref Final Report")
verbose_name_plural = _("Dref Final Reports")
Expand Down Expand Up @@ -1728,11 +1731,9 @@ class SourceModel(models.IntegerChoices):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

# 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.IntegerField(
verbose_name=_("source model name"),
source = models.IntegerField(
verbose_name=_("source"),
help_text=_("The model this summary was generated from."),
choices=SourceModel.choices,
)
source_id = models.PositiveBigIntegerField(
Expand Down
102 changes: 64 additions & 38 deletions dref/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
import hashlib
import json
import logging
from typing import Callable, Dict, List, Optional
from typing import Callable, Dict, List, Optional, Union

import tiktoken
from django.conf import settings
from django.utils.functional import cached_property
from openai import AzureOpenAI
from django.db.models import F

from api.utils import get_model_name
from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate
from dref.models import Dref, DrefFinalReport, DrefOperationalUpdate, DrefSummary
from main.llm import get_dref_summary_llm_client

logger = logging.getLogger(__name__)

Expand All @@ -22,6 +21,16 @@
MAX_OUTPUT_CHARS_PER_FIELD = 1500
MAX_INPUT_TOKENS = 10000


# The models a DrefSummary can be generated from.
DrefSummarySource = Union[Dref, DrefOperationalUpdate, DrefFinalReport]

SOURCE_BY_MODEL: Dict[type, DrefSummary.SourceModel] = {
Dref: DrefSummary.SourceModel.DREF,
DrefOperationalUpdate: DrefSummary.SourceModel.DREF_OPERATIONAL_UPDATE,
DrefFinalReport: DrefSummary.SourceModel.DREF_FINAL_REPORT,
}

# DrefSummary fields — order is the iteration order for prompt assembly.
SUMMARY_FIELDS: List[str] = [
"situational_overview",
Expand Down Expand Up @@ -121,28 +130,6 @@ def _build_lessons_learned_prompt(**kwargs) -> str:
)


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))
Expand Down Expand Up @@ -184,8 +171,8 @@ def _extract_fields(obj, field_names: List[str]) -> dict:
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()
def __init__(self):
self.client = get_dref_summary_llm_client()

# Shared helpers — called by multiple extractors

Expand Down Expand Up @@ -302,8 +289,13 @@ def _extract_dref_final_kwargs(cls, final) -> Dict[str, dict]:
return kwargs

@classmethod
def _get_section_kwargs(cls, source_doc) -> Dict[str, dict]:
"""Dispatch to the model-specific extractor."""
def get_section_kwargs(cls, source_doc: DrefSummarySource) -> Dict[str, dict]:
"""Dispatch to the model-specific extractor.

Public so callers that need both the hash and the generated summary
for the same ``source_doc`` (see ``compute_source_hash``/``generate_all``)
can compute this once and pass it to both instead of extracting twice.
"""
if isinstance(source_doc, Dref):
return cls._extract_dref_kwargs(source_doc)
if isinstance(source_doc, DrefOperationalUpdate):
Expand All @@ -312,16 +304,47 @@ def _get_section_kwargs(cls, source_doc) -> Dict[str, dict]:
return cls._extract_dref_final_kwargs(source_doc)
return {}

@staticmethod
def get_latest_approved_source(dref: Dref) -> Optional[tuple[DrefSummary.SourceModel, DrefSummarySource]]:
"""Most authoritative (source, source object) pair for ``dref``.

Priority: Final Report > latest Operational Update > Dref itself.
"""
final_report = (
DrefFinalReport.objects.select_related("country", "disaster_type")
.filter(dref=dref, status=Dref.Status.APPROVED)
.order_by("-created_at")
.first()
)
if final_report:
return SOURCE_BY_MODEL[DrefFinalReport], final_report

latest_ops_update = (
DrefOperationalUpdate.objects.select_related("country", "disaster_type")
.filter(dref=dref, status=Dref.Status.APPROVED)
.order_by(F("operational_update_number").desc(nulls_last=True), "-created_at")
.first()
)
if latest_ops_update:
return SOURCE_BY_MODEL[DrefOperationalUpdate], latest_ops_update

if dref.status == Dref.Status.APPROVED:
return SOURCE_BY_MODEL[Dref], dref

return None

@classmethod
def build_source_text(cls, source_doc) -> str:
def build_source_text(cls, source_doc: DrefSummarySource) -> 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)
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:
def compute_source_hash(cls, source_doc: DrefSummarySource, section_kwargs: Optional[Dict[str, dict]] = None) -> str:
"""Hash of all source content feeding the summary, for change detection."""
model_label = get_model_name(type(source_doc))
payload = {"model": model_label, "id": source_doc.id, "source": cls._get_section_kwargs(source_doc)}
if section_kwargs is None:
section_kwargs = cls.get_section_kwargs(source_doc)
payload = {"model": model_label, "id": source_doc.id, "source": section_kwargs}
content = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha256(content.encode("utf-8")).hexdigest()

Expand All @@ -337,16 +360,19 @@ def _parse_response(response: str) -> Dict[str, str]:
raise ValueError("Expected a JSON object of summary fields")
return data

def generate_all(self, source_doc) -> Dict[str, str]:
def generate_all(self, source_doc: DrefSummarySource, section_kwargs: Optional[Dict[str, dict]] = None) -> 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.
instead of leaving them in place on regeneration. ``section_kwargs``
can be passed in by a caller that already computed it (e.g. via
``compute_source_hash``) to avoid re-extracting it from ``source_doc``.
"""
empty_results: Dict[str, str] = {field_name: "" for field_name in SUMMARY_FIELDS}

section_kwargs = self._get_section_kwargs(source_doc)
if section_kwargs is None:
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
Expand Down
Loading
Loading