using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using PlotLine.Data; using PlotLine.Services; using PlotLine.Models; if (args.Length < 3 || !int.TryParse(args[0], out var bookId) || !int.TryParse(args[1], out var userId)) throw new ArgumentException("Usage: [--apply]. Set PLOT_DIRECTOR_SQL_CONNECTION securely. Default: dry run."); var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ConnectionStrings:DefaultConnection"] = Environment.GetEnvironmentVariable("PLOT_DIRECTOR_SQL_CONNECTION") }).Build(); var factory = new SqlConnectionFactory(config); using (var connection = factory.CreateConnection()) { connection.Open(); if (connection.Database != args[2]) throw new InvalidOperationException("Unexpected database; no changes made."); } var pipeline = new StoryIntelligencePipelineRepository(factory); var committed = await pipeline.ListCommittedRunsByBookAsync(bookId, userId); var runs = new StoryIntelligenceResultRepository(factory); var sourceRuns = await runs.ListRunsByBookForUserAsync(bookId, userId); if (sourceRuns.Any(x => x.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running)) throw new InvalidOperationException("Analysis is active; wait for completion before backfilling."); var items = committed.Count > 0 ? committed.Select(x => new OnboardingStoryIntelligenceBatchItem { ChapterID = x.ChapterID, RunID = x.StoryIntelligenceRunID }).ToList() : sourceRuns.Where(x => x.ChapterID.HasValue && x.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings) .Select(x => new OnboardingStoryIntelligenceBatchItem { ChapterID = x.ChapterID!.Value, RunID = x.StoryIntelligenceRunID }).ToList(); var projectId = committed.FirstOrDefault()?.ProjectID ?? sourceRuns.FirstOrDefault()?.ProjectID; if (items.Count == 0 || !projectId.HasValue) throw new InvalidOperationException("No accessible scene analysis for this book."); var batch = new OnboardingStoryIntelligenceBatch { BookID = bookId, UserID = userId, ProjectID = projectId.Value, Items = items }; var importer = new StoryIntelligenceLocationImportService(new StoryIntelligenceResultRepository(factory), new LocationRepository(factory), new SceneRepository(factory), null!, NullLogger.Instance); var assessments = await importer.BuildSignificanceAsync(batch); var repository = new StoryIntelligenceReviewCandidateRepository(factory); var rows = await repository.ListLocationsAsync(bookId); var missing = rows.Where(x => !assessments.ContainsKey(x.StableKey)).ToList(); Console.WriteLine($"Book {bookId}: {rows.Count} persisted locations; {rows.Count - missing.Count} matched to scene evidence; {missing.Count} unmatched."); if (missing.Count > 0) throw new InvalidOperationException("Backfill stopped: candidate identities no longer match the source analysis. No decisions or canonical locations were changed."); foreach (var group in rows.GroupBy(x => assessments[x.StableKey].Classification)) Console.WriteLine($"{group.Key}: {group.Count()}"); foreach (var row in rows.OrderByDescending(x => assessments[x.StableKey].Score).Take(12)) Console.WriteLine($"{row.DisplayName}: {assessments[row.StableKey].Score} ({assessments[row.StableKey].Classification})"); if (args.Contains("--apply")) { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); await repository.BackfillLocationSignificanceAsync(bookId, JsonSerializer.Serialize(rows.Select(x => new { key = x.StableKey, assessment = assessments[x.StableKey] }), options)); Console.WriteLine("Saved assessments; identities, decisions and canonical data preserved."); } else Console.WriteLine("Dry run; no changes made.");