++
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.action import ActionEntry, ReactiveResponse
|
||||
from app.models.world import WorldState, WorldMetrics
|
||||
from app.services.llm import LLMClient, parse_json
|
||||
from app.services.i18n import DEFAULT_LOCALE, language_directive as _language_directive, localize_report
|
||||
from app.constants import TIMES_OF_DAY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -127,9 +129,13 @@ Return JSON:
|
||||
|
||||
|
||||
class Narrator:
|
||||
def __init__(self, llm: LLMClient, forecast=None):
|
||||
def __init__(self, llm: LLMClient, forecast=None, locale: str = DEFAULT_LOCALE):
|
||||
self.llm = llm
|
||||
self._forecast = forecast
|
||||
self._locale = locale
|
||||
|
||||
def set_locale(self, locale: str) -> None:
|
||||
self._locale = locale
|
||||
|
||||
async def narrate_round(
|
||||
self,
|
||||
@@ -170,10 +176,11 @@ class Narrator:
|
||||
|
||||
time_of_day = TIMES_OF_DAY[world_state.round_in_day % len(TIMES_OF_DAY)]
|
||||
|
||||
system = NARRATE_SYSTEM.format(
|
||||
head, tail = _language_directive(self._locale)
|
||||
system = head + "\n\n" + NARRATE_SYSTEM.format(
|
||||
world_name=world_state.blueprint.name,
|
||||
rules="; ".join(world_state.blueprint.rules),
|
||||
)
|
||||
) + f"\n\n{tail}"
|
||||
user = NARRATE_USER.format(
|
||||
previous_narrative=previous_narrative or "This is the beginning of the story.",
|
||||
time_of_day=time_of_day,
|
||||
@@ -195,7 +202,8 @@ class Narrator:
|
||||
day_start: int,
|
||||
day_end: int,
|
||||
) -> str:
|
||||
system = EPOCH_SYSTEM.format(world_name=world_name, rules="; ".join(rules))
|
||||
head, tail = _language_directive(self._locale)
|
||||
system = head + "\n\n" + EPOCH_SYSTEM.format(world_name=world_name, rules="; ".join(rules)) + f"\n\n{tail}"
|
||||
narrative_text = "\n\n---\n\n".join(narratives[:20])
|
||||
|
||||
metrics_delta = (
|
||||
@@ -232,8 +240,11 @@ class Narrator:
|
||||
# --- Pass 0: Epoch summaries (existing logic) ---
|
||||
epoch_size = 30
|
||||
total_days = world_state.day
|
||||
epochs = []
|
||||
|
||||
# Build the list of epochs that have narratives to summarize, then
|
||||
# call the LLM concurrently for all of them. Epochs without any
|
||||
# narratives (gap days) are skipped entirely.
|
||||
pending_epochs: list[dict] = []
|
||||
for start_day in range(1, total_days + 1, epoch_size):
|
||||
end_day = min(start_day + epoch_size - 1, total_days)
|
||||
epoch_narrs = [n["text"] for n in all_narratives if start_day <= n.get("day", 0) <= end_day]
|
||||
@@ -249,10 +260,30 @@ class Narrator:
|
||||
if day_of_round <= end_day:
|
||||
m_end = WorldMetrics(**{k: m.get(k, getattr(WorldMetrics(), k)) for k in WorldMetrics.model_fields})
|
||||
|
||||
summary = await self.summarize_epoch(
|
||||
blueprint.name, blueprint.rules, epoch_narrs, m_start, m_end, start_day, end_day
|
||||
pending_epochs.append({
|
||||
"start_day": start_day,
|
||||
"end_day": end_day,
|
||||
"narrs": epoch_narrs,
|
||||
"m_start": m_start,
|
||||
"m_end": m_end,
|
||||
})
|
||||
|
||||
# Run all epoch summaries concurrently. The LLMClient already caps
|
||||
# concurrency at 10 via its semaphore, so this just unblocks the
|
||||
# event loop — we don't need an extra gather limit. Running 12 epochs
|
||||
# serially could take 4+ minutes for a 365-day sim; concurrent runs
|
||||
# collapse this to ~30s (the wall time of the slowest call).
|
||||
epoch_summaries = await asyncio.gather(*(
|
||||
self.summarize_epoch(
|
||||
blueprint.name, blueprint.rules, ep["narrs"],
|
||||
ep["m_start"], ep["m_end"], ep["start_day"], ep["end_day"],
|
||||
)
|
||||
epochs.append({"days": f"{start_day}-{end_day}", "summary": summary})
|
||||
for ep in pending_epochs
|
||||
))
|
||||
epochs = [
|
||||
{"days": f"{ep['start_day']}-{ep['end_day']}", "summary": summary}
|
||||
for ep, summary in zip(pending_epochs, epoch_summaries)
|
||||
]
|
||||
|
||||
epoch_text = "\n\n".join(f"Days {e['days']}:\n{e['summary']}" for e in epochs)
|
||||
|
||||
@@ -316,11 +347,12 @@ class Narrator:
|
||||
for a in agent_stats
|
||||
)
|
||||
|
||||
system_p2 = INTERPRET_METRICS_SYSTEM.format(
|
||||
head, tail = _language_directive(self._locale)
|
||||
system_p2 = head + "\n\n" + INTERPRET_METRICS_SYSTEM.format(
|
||||
world_name=blueprint.name,
|
||||
rules="; ".join(blueprint.rules),
|
||||
confidence=confidence,
|
||||
)
|
||||
) + f"\n\n{tail}"
|
||||
user_p2 = (
|
||||
f"Epoch summaries:\n{epoch_text}\n\n"
|
||||
f"Metric stats:\n{metrics_summary}\n\n"
|
||||
@@ -358,10 +390,11 @@ class Narrator:
|
||||
if seg["top_thoughts"]:
|
||||
segment_input += "Recent thoughts:\n" + "\n".join(f" - {t}" for t in seg["top_thoughts"][-8:]) + "\n"
|
||||
|
||||
system_p3 = SEGMENT_ANALYSIS_SYSTEM.format(
|
||||
head, tail = _language_directive(self._locale)
|
||||
system_p3 = head + "\n\n" + SEGMENT_ANALYSIS_SYSTEM.format(
|
||||
world_name=blueprint.name,
|
||||
rules="; ".join(blueprint.rules),
|
||||
)
|
||||
) + f"\n\n{tail}"
|
||||
user_p3 = f"Segment data:\n{segment_input}"
|
||||
|
||||
response_p3 = await self.llm.generate(system=system_p3, user=user_p3, json_mode=True, max_tokens=1500)
|
||||
@@ -398,10 +431,11 @@ class Narrator:
|
||||
)
|
||||
)
|
||||
|
||||
system_p4 = ACTION_SYNTHESIS_SYSTEM.format(
|
||||
head, tail = _language_directive(self._locale)
|
||||
system_p4 = head + "\n\n" + ACTION_SYNTHESIS_SYSTEM.format(
|
||||
world_name=blueprint.name,
|
||||
rules="; ".join(blueprint.rules),
|
||||
)
|
||||
) + f"\n\n{tail}"
|
||||
|
||||
response_p4 = await self.llm.generate(system=system_p4, user=synthesis_context, json_mode=True, max_tokens=1500)
|
||||
pass4 = parse_json(response_p4)
|
||||
@@ -415,7 +449,7 @@ class Narrator:
|
||||
"description": f"{p['label']} shifted from {p['value_before']:.2f} to {p['value_after']:.2f}",
|
||||
})
|
||||
|
||||
return {
|
||||
report = {
|
||||
"executive_brief": executive_brief,
|
||||
"scorecard": scorecard,
|
||||
"insights": insights,
|
||||
@@ -442,3 +476,11 @@ class Narrator:
|
||||
"counterfactuals": counterfactual_data,
|
||||
"agent_coherence": coherence_data,
|
||||
}
|
||||
# We keep canonical English enum strings (label/rating/trend/
|
||||
# verdict/confidence) in the response so the frontend can both:
|
||||
# 1) pick the right CSS class for styling, and
|
||||
# 2) translate the label for display via vue-i18n.
|
||||
# LLM-generated free-text fields (summary, headlines, insights,
|
||||
# action items, quotes) stay as-is — the prompt-level language
|
||||
# directive should already have made them Chinese for zh users.
|
||||
return report
|
||||
|
||||
Reference in New Issue
Block a user