Skip to content

Commit c1d1a5b

Browse files
committed
Enrich zone brief LLM with full conflict history and live feed headlines
1 parent d0d7132 commit c1d1a5b

5 files changed

Lines changed: 132 additions & 3 deletions

File tree

deploy/install.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,20 @@ preflight_tls_checks() {
468468
fi
469469
}
470470

471+
prompt_reset_zone_briefs() {
472+
local env_file="$INSTALL_DIR/.env"
473+
local choice
474+
choice="$(read_prompt "Reset conflict history and analysis? [no]: ")"
475+
choice="${choice:-no}"
476+
choice="$(echo "$choice" | tr '[:upper:]' '[:lower:]')"
477+
if [[ "$choice" == "yes" || "$choice" == "y" ]]; then
478+
upsert_env "$env_file" "RESET_ZONE_BRIEF_LLM" "1"
479+
info "Zone brief LLM history will be regenerated on next collector startup."
480+
else
481+
upsert_env "$env_file" "RESET_ZONE_BRIEF_LLM" "0"
482+
fi
483+
}
484+
471485
start_stack() {
472486
local start_choice
473487
start_choice="$(read_prompt "Start/restart EUOSINT now? [yes]: ")"
@@ -566,6 +580,7 @@ main() {
566580
refresh_watchdog_files_direct
567581

568582
configure_env
583+
prompt_reset_zone_briefs
569584
print_runtime_summary
570585
start_stack
571586
install_user_watchdog_timer

internal/collector/app/app.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,17 @@ func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer)
182182
fmt.Fprintf(stdout, "Search API listening on %s\n", cfg.APIAddr)
183183
}
184184

185+
if cfg.ResetZoneBriefLLM && strings.HasSuffix(cfg.RegistryPath, ".db") {
186+
if db, err := sourcedb.Open(cfg.RegistryPath); err == nil {
187+
if err := db.ResetZoneBriefLLM(ctx); err != nil {
188+
fmt.Fprintf(stderr, "WARN reset zone brief LLM: %v\n", err)
189+
} else {
190+
fmt.Fprintf(stdout, "Reset zone brief LLM history and analysis — will regenerate on next cycle\n")
191+
}
192+
db.Close()
193+
}
194+
}
195+
185196
return run.New(stdout, stderr).Run(ctx, cfg)
186197
}
187198

internal/collector/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ type Config struct {
137137
CollectorRole string
138138
CORSAllowedOrigins []string
139139
APIBearerToken string
140+
ResetZoneBriefLLM bool
140141
}
141142

142143
func Default() Config {
@@ -351,6 +352,7 @@ func FromEnv() Config {
351352
cfg.CollectorRole = strings.ToLower(strings.TrimSpace(envString("COLLECTOR_ROLE", cfg.CollectorRole)))
352353
cfg.CORSAllowedOrigins = envCSV("CORS_ALLOWED_ORIGINS", cfg.CORSAllowedOrigins)
353354
cfg.APIBearerToken = envString("API_BEARER_TOKEN", cfg.APIBearerToken)
355+
cfg.ResetZoneBriefLLM = envBool("RESET_ZONE_BRIEF_LLM", cfg.ResetZoneBriefLLM)
354356
return cfg
355357
}
356358

internal/collector/run/run.go

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2346,10 +2346,15 @@ func (r Runner) writeZoneBriefings(ctx context.Context, cfg config.Config, sourc
23462346
}
23472347
recentEventsByCountryID[countryID] = toConflictRecentEvents(items, 5)
23482348
}
2349+
// Load recent alert headlines keyed by GWNO country ID for LLM current analysis.
2350+
recentHeadlinesByGWNO := buildRecentHeadlinesByGWNO(ctx, cfg, gwnoToISO2Map)
2351+
// Build per-country conflict history from the full (non-deduplicated) conflict list.
2352+
allConflictsByCountry := groupConflictsByPrimaryCountry(conflicts)
23492353
llmNarrativesByCountryID := map[string]sourcedb.ZoneBriefLLM{}
23502354
if zoneDB != nil && strings.TrimSpace(cfg.VettingAPIKey) != "" {
23512355
for _, row := range currentConflicts {
2352-
narrative, err := r.ensureZoneBriefLLMSummary(ctx, cfg, zoneDB, row, latestYear, cumulativeBattleDeaths, latestYearBattleDeaths)
2356+
countryID := conflictPrimaryCountryID(row)
2357+
narrative, err := r.ensureZoneBriefLLMSummary(ctx, cfg, zoneDB, row, latestYear, cumulativeBattleDeaths, latestYearBattleDeaths, recentHeadlinesByGWNO[countryID], allConflictsByCountry[countryID])
23532358
if err != nil {
23542359
continue
23552360
}
@@ -4265,6 +4270,8 @@ func (r Runner) ensureZoneBriefLLMSummary(
42654270
latestYear int,
42664271
cumulativeByCountry map[string]int,
42674272
latestYearByCountry map[string]int,
4273+
recentHeadlines []string,
4274+
allCountryConflicts []parse.UCDPConflict,
42684275
) (sourcedb.ZoneBriefLLM, error) {
42694276
if cacheDB == nil || strings.TrimSpace(cfg.VettingAPIKey) == "" {
42704277
return sourcedb.ZoneBriefLLM{}, nil
@@ -4320,9 +4327,27 @@ func (r Runner) ensureZoneBriefLLMSummary(
43204327
zoneLabel = strings.TrimSpace(sideA) + " vs " + strings.TrimSpace(sideB)
43214328
}
43224329
if needsHistorical {
4330+
historicalContext := baseContext
4331+
if len(allCountryConflicts) > 0 {
4332+
historicalContext += "\n\nAll recorded conflicts for this country (UCDP dataset):"
4333+
seen := map[string]struct{}{}
4334+
for _, c := range allCountryConflicts {
4335+
key := c.ConflictID
4336+
if _, ok := seen[key]; ok {
4337+
continue
4338+
}
4339+
seen[key] = struct{}{}
4340+
label := strings.TrimSpace(c.SideA)
4341+
if strings.TrimSpace(c.SideB) != "" {
4342+
label += " vs " + strings.TrimSpace(c.SideB)
4343+
}
4344+
historicalContext += fmt.Sprintf("\n- %s (id=%s, type=%s, start=%s, ended=%v)",
4345+
label, c.ConflictID, parse.NormalizeConflictType(c.TypeOfConflict), c.StartDate, c.EPEnd != 0)
4346+
}
4347+
}
43234348
msgs := []vet.Message{
43244349
{Role: "system", Content: "You are an OSINT analyst. Return plain text only. Facts only. Neutral tone. No fluff. No speculation."},
4325-
{Role: "user", Content: "Short historic summary about conflict zone " + zoneLabel + " in max 80 words and current analysis in max 60 words.\nNow return only the historic summary block.\nConstraints: factual only, no bullets, no disclaimers, no filler.\n" + baseContext},
4350+
{Role: "user", Content: "Short historic summary about conflict zone " + zoneLabel + " in max 80 words and current analysis in max 60 words.\nNow return only the historic summary block.\nConstraints: factual only, no bullets, no disclaimers, no filler.\nCover all major conflicts listed, not just the current one.\n" + historicalContext},
43264351
}
43274352
resp, err := llm.Complete(ctx, msgs)
43284353
if err == nil && strings.TrimSpace(resp) != "" {
@@ -4331,9 +4356,17 @@ func (r Runner) ensureZoneBriefLLMSummary(
43314356
}
43324357
}
43334358
if needsAnalysis {
4359+
analysisContext := baseContext
4360+
if len(recentHeadlines) > 0 {
4361+
limit := len(recentHeadlines)
4362+
if limit > 20 {
4363+
limit = 20
4364+
}
4365+
analysisContext += "\n\nRecent alert headlines from live feeds (last 48h):\n- " + strings.Join(recentHeadlines[:limit], "\n- ")
4366+
}
43344367
msgs := []vet.Message{
43354368
{Role: "system", Content: "You are an OSINT analyst. Return plain text only. Facts only. Neutral tone. No fluff. No speculation."},
4336-
{Role: "user", Content: "Short historic summary about conflict zone " + zoneLabel + " in max 80 words and current analysis in max 60 words.\nNow return only the current analysis block.\nConstraints: factual only, no bullets, no disclaimers, no filler.\nFocus only on current dynamics (roughly last 6-12 months): momentum, intensity direction, territorial/control shifts, and near-term operational outlook.\nDo NOT repeat conflict start date or cumulative death totals from historical summary.\nIf recent evidence is weak, give a cautious best-available assessment from the provided context.\nAs-of date: " + time.Now().UTC().Format("2006-01-02") + "\n" + baseContext},
4369+
{Role: "user", Content: "Short historic summary about conflict zone " + zoneLabel + " in max 80 words and current analysis in max 60 words.\nNow return only the current analysis block.\nConstraints: factual only, no bullets, no disclaimers, no filler.\nFocus only on current dynamics (roughly last 6-12 months): momentum, intensity direction, territorial/control shifts, and near-term operational outlook.\nDo NOT repeat conflict start date or cumulative death totals from historical summary.\nIncorporate the recent alert headlines if provided — they represent real-time intelligence from live OSINT feeds.\nAs-of date: " + time.Now().UTC().Format("2006-01-02") + "\n" + analysisContext},
43374370
}
43384371
resp, err := llm.Complete(ctx, msgs)
43394372
if err == nil && strings.TrimSpace(resp) != "" {
@@ -4367,6 +4400,63 @@ func ucdpConflictHeadHash(conflicts []parse.UCDPConflict) string {
43674400
return hex.EncodeToString(h.Sum(nil))
43684401
}
43694402

4403+
// groupConflictsByPrimaryCountry groups the full UCDP conflict list by primary GWNO,
4404+
// deduplicating by conflict_id (keeping the most recent year entry).
4405+
func groupConflictsByPrimaryCountry(conflicts []parse.UCDPConflict) map[string][]parse.UCDPConflict {
4406+
// First deduplicate by conflict_id keeping the highest year.
4407+
best := map[string]parse.UCDPConflict{}
4408+
for _, c := range conflicts {
4409+
if existing, ok := best[c.ConflictID]; !ok || c.Year > existing.Year {
4410+
best[c.ConflictID] = c
4411+
}
4412+
}
4413+
out := map[string][]parse.UCDPConflict{}
4414+
for _, c := range best {
4415+
primary := firstUCDPCode(c.GWNoLoc)
4416+
if primary == "" {
4417+
continue
4418+
}
4419+
out[primary] = append(out[primary], c)
4420+
}
4421+
return out
4422+
}
4423+
4424+
// buildRecentHeadlinesByGWNO loads current alerts and groups titles by GWNO country ID.
4425+
// This provides real-time context for the LLM current analysis prompt.
4426+
func buildRecentHeadlinesByGWNO(ctx context.Context, cfg config.Config, gwnoToISO2 map[string]string) map[string][]string {
4427+
out := map[string][]string{}
4428+
alerts, err := loadPreviousAlerts(ctx, cfg)
4429+
if err != nil || len(alerts) == 0 {
4430+
return out
4431+
}
4432+
// Build reverse map: ISO2 → []GWNO
4433+
iso2ToGWNOs := map[string][]string{}
4434+
for gwno, iso2 := range gwnoToISO2 {
4435+
iso2ToGWNOs[iso2] = append(iso2ToGWNOs[iso2], gwno)
4436+
}
4437+
cutoff := time.Now().UTC().Add(-48 * time.Hour)
4438+
for _, a := range alerts {
4439+
cc := strings.ToUpper(strings.TrimSpace(a.EventCountryCode))
4440+
if cc == "" || strings.TrimSpace(a.Title) == "" {
4441+
continue
4442+
}
4443+
if t, err := time.Parse(time.RFC3339, a.FirstSeen); err == nil && t.Before(cutoff) {
4444+
continue
4445+
}
4446+
gwnos := iso2ToGWNOs[cc]
4447+
if len(gwnos) == 0 {
4448+
continue
4449+
}
4450+
title := strings.TrimSpace(a.Title)
4451+
for _, gwno := range gwnos {
4452+
if len(out[gwno]) < 30 {
4453+
out[gwno] = append(out[gwno], title)
4454+
}
4455+
}
4456+
}
4457+
return out
4458+
}
4459+
43704460
func limitWords(text string, maxWords int) string {
43714461
if maxWords <= 0 {
43724462
return ""

internal/sourcedb/zone_brief_llm.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,14 @@ ON CONFLICT(country_id) DO UPDATE SET
9393
}
9494
return nil
9595
}
96+
97+
func (db *DB) ResetZoneBriefLLM(ctx context.Context) error {
98+
if err := db.Init(ctx); err != nil {
99+
return err
100+
}
101+
_, err := db.sql.ExecContext(ctx, `DELETE FROM zone_brief_llm`)
102+
if err != nil {
103+
return fmt.Errorf("reset zone brief llm: %w", err)
104+
}
105+
return nil
106+
}

0 commit comments

Comments
 (0)