Use strict structured scene intelligence output
This commit is contained in:
parent
6d367b8977
commit
9c2a3215c5
@ -82,6 +82,12 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
|
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
|
||||||
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
|
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
|
||||||
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
|
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
|
||||||
|
("Story Intelligence scene call uses strict structured output", StoryIntelligenceSceneCallUsesStrictStructuredOutput),
|
||||||
|
("Story Intelligence scene schema constrains confidence numbers", StoryIntelligenceSceneSchemaConstrainsConfidenceNumbers),
|
||||||
|
("Story Intelligence scene schema constrains evidence strings", StoryIntelligenceSceneSchemaConstrainsEvidenceStrings),
|
||||||
|
("Story Intelligence structured metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport),
|
||||||
|
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
|
||||||
|
("Story Intelligence structured schema remains fixture agnostic", StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic),
|
||||||
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
|
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
|
||||||
("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress),
|
("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress),
|
||||||
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
|
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
|
||||||
@ -4258,7 +4264,9 @@ static void AiPovJsonParsesIntoStructuralModels()
|
|||||||
var parsed = JsonSerializer.Deserialize<ChapterStructureModel>(json, JsonOptions());
|
var parsed = JsonSerializer.Deserialize<ChapterStructureModel>(json, JsonOptions());
|
||||||
Assert(parsed?.DefaultPov?.CharacterName == "Maggie", "AI-returned defaultPov should parse.");
|
Assert(parsed?.DefaultPov?.CharacterName == "Maggie", "AI-returned defaultPov should parse.");
|
||||||
Assert(parsed?.MixedPov == true, "AI-returned mixedPov should parse.");
|
Assert(parsed?.MixedPov == true, "AI-returned mixedPov should parse.");
|
||||||
Assert(parsed is not null && parsed.SceneBoundaries[1].PovOverride?.CharacterName == "Beth", "AI-returned scene povOverride should parse.");
|
var boundaries = parsed?.SceneBoundaries;
|
||||||
|
var secondBoundary = boundaries is { Count: > 1 } ? boundaries[1] : null;
|
||||||
|
Assert(secondBoundary?.PovOverride?.CharacterName == "Beth", "AI-returned scene povOverride should parse.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void PostCharacterReviewResolvesPersistedStructuralPov()
|
static void PostCharacterReviewResolvesPersistedStructuralPov()
|
||||||
@ -4717,6 +4725,191 @@ static void NarrativeProductionCodeRemainsFixtureAgnostic()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceSceneCallUsesStrictStructuredOutput()
|
||||||
|
{
|
||||||
|
string? requestBody = null;
|
||||||
|
var client = BuildStoryIntelligenceClient(request =>
|
||||||
|
{
|
||||||
|
requestBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
return OpenAiSuccessResponse("""{"schemaVersion":"1.0"}""");
|
||||||
|
});
|
||||||
|
|
||||||
|
client.ExecutePromptAsync(
|
||||||
|
"Analyse this scene.",
|
||||||
|
"Scene-Prompt-V2",
|
||||||
|
CancellationToken.None,
|
||||||
|
"gpt-5-mini",
|
||||||
|
128,
|
||||||
|
StoryIntelligenceSceneStructuredOutputSchema.Contract)
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(requestBody ?? throw new InvalidOperationException("No request body captured."));
|
||||||
|
var root = document.RootElement;
|
||||||
|
var format = root.GetProperty("text").GetProperty("format");
|
||||||
|
Assert(root.GetProperty("model").GetString() == "gpt-5-mini", "Structured scene request should retain the selected scene model.");
|
||||||
|
Assert(format.GetProperty("type").GetString() == "json_schema", "Scene request should use Responses API text.format json_schema.");
|
||||||
|
Assert(format.GetProperty("name").GetString() == StoryIntelligenceSceneStructuredOutputSchema.ContractName, "Scene request should use the scene intelligence contract name.");
|
||||||
|
Assert(format.GetProperty("strict").GetBoolean(), "Scene request should enable strict structured output.");
|
||||||
|
Assert(format.GetProperty("schema").GetProperty("additionalProperties").GetBoolean() == false, "Root schema must be closed.");
|
||||||
|
|
||||||
|
var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs"));
|
||||||
|
Assert(runner.Contains("StoryIntelligenceSceneStructuredOutputSchema.Contract", StringComparison.Ordinal), "Persisted scene analysis should pass the strict scene contract.");
|
||||||
|
Assert(runner.Contains("var retryClientResult = await client.ExecutePromptAsync(", StringComparison.Ordinal), "Legacy JSON repair fallback should remain present.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceSceneSchemaConstrainsConfidenceNumbers()
|
||||||
|
{
|
||||||
|
var schema = SceneSchemaRoot();
|
||||||
|
var properties = schema.GetProperty("properties");
|
||||||
|
AssertConfidence(properties.GetProperty("scenePurpose").GetProperty("properties").GetProperty("confidence"), "scenePurpose.confidence");
|
||||||
|
AssertConfidence(properties.GetProperty("pointOfView").GetProperty("properties").GetProperty("confidence"), "pointOfView.confidence");
|
||||||
|
AssertConfidence(properties.GetProperty("setting").GetProperty("properties").GetProperty("confidence"), "setting.confidence");
|
||||||
|
AssertConfidence(ArrayItem(properties.GetProperty("characters")).GetProperty("properties").GetProperty("confidence"), "characters.confidence");
|
||||||
|
AssertConfidence(ArrayItem(properties.GetProperty("relationships")).GetProperty("properties").GetProperty("confidence"), "relationships.confidence");
|
||||||
|
AssertConfidence(ArrayItem(properties.GetProperty("knowledgeChanges")).GetProperty("properties").GetProperty("confidence"), "knowledgeChanges.confidence");
|
||||||
|
AssertConfidence(ArrayItem(properties.GetProperty("observations")).GetProperty("properties").GetProperty("confidence"), "observations.confidence");
|
||||||
|
|
||||||
|
var schemaText = JsonSerializer.Serialize(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
|
||||||
|
Assert(!schemaText.Contains("0. nine", StringComparison.OrdinalIgnoreCase), "Production schema must not patch the observed malformed confidence phrase.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceSceneSchemaConstrainsEvidenceStrings()
|
||||||
|
{
|
||||||
|
var properties = SceneSchemaRoot().GetProperty("properties");
|
||||||
|
AssertString(properties.GetProperty("pointOfView").GetProperty("properties").GetProperty("evidence"), "pointOfView.evidence", nullable: true);
|
||||||
|
AssertString(ArrayItem(properties.GetProperty("relationships")).GetProperty("properties").GetProperty("evidence"), "relationships.evidence");
|
||||||
|
AssertString(ArrayItem(properties.GetProperty("knowledgeChanges")).GetProperty("properties").GetProperty("evidence"), "knowledgeChanges.evidence");
|
||||||
|
AssertString(ArrayItem(properties.GetProperty("timelineClues")).GetProperty("properties").GetProperty("evidence"), "timelineClues.evidence");
|
||||||
|
AssertString(ArrayItem(properties.GetProperty("narrativeArcs")).GetProperty("properties").GetProperty("evidence"), "narrativeArcs.evidence");
|
||||||
|
AssertString(ArrayItem(properties.GetProperty("questionsRaised")).GetProperty("properties").GetProperty("evidence"), "questionsRaised.evidence");
|
||||||
|
AssertString(ArrayItem(properties.GetProperty("questionsAnswered")).GetProperty("properties").GetProperty("evidence"), "questionsAnswered.evidence");
|
||||||
|
AssertString(ArrayItem(properties.GetProperty("observations")).GetProperty("properties").GetProperty("evidence"), "observations.evidence");
|
||||||
|
|
||||||
|
var schemaText = JsonSerializer.Serialize(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
|
||||||
|
Assert(!schemaText.Contains("Her mum was late", StringComparison.OrdinalIgnoreCase), "Production schema must not patch the observed malformed evidence sentence.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
|
||||||
|
{
|
||||||
|
var validator = new StorySceneValidator();
|
||||||
|
var scene = RepresentativeStructuredScene();
|
||||||
|
var validation = validator.Validate(scene);
|
||||||
|
|
||||||
|
Assert(validation.IsValid, string.Join("; ", validation.Errors.Select(error => $"{error.Path}: {error.Message}")));
|
||||||
|
|
||||||
|
var commit = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceImportCommitService.cs"));
|
||||||
|
Assert(commit.Contains("parsed.MetricValues", StringComparison.Ordinal), "Import should normalise strict metricValues for existing metric persistence.");
|
||||||
|
Assert(commit.Contains("metrics[metric.Key.Trim()]", StringComparison.Ordinal), "Metric normalisation should key values by configured metric key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceStructuredSchemaCoversCompleteResponseModel()
|
||||||
|
{
|
||||||
|
var properties = SceneSchemaRoot().GetProperty("properties");
|
||||||
|
var required = properties.EnumerateObject().Select(property => property.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
|
var expected = new[]
|
||||||
|
{
|
||||||
|
"schemaVersion",
|
||||||
|
"sceneReference",
|
||||||
|
"scenePurpose",
|
||||||
|
"pointOfView",
|
||||||
|
"setting",
|
||||||
|
"characters",
|
||||||
|
"characterAppearance",
|
||||||
|
"locations",
|
||||||
|
"assets",
|
||||||
|
"relationships",
|
||||||
|
"knowledgeChanges",
|
||||||
|
"timelineClues",
|
||||||
|
"narrativeArcs",
|
||||||
|
"questionsRaised",
|
||||||
|
"questionsAnswered",
|
||||||
|
"observations",
|
||||||
|
"metricValues",
|
||||||
|
"sourceLimits"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var name in expected)
|
||||||
|
{
|
||||||
|
Assert(required.Contains(name), $"Strict scene schema is missing {name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert(ArrayItem(properties.GetProperty("narrativeArcs")).GetProperty("properties").TryGetProperty("matchingExistingThreadId", out _), "Narrative signals should include existing Thread matching.");
|
||||||
|
Assert(ArrayItem(properties.GetProperty("characterAppearance")).GetProperty("properties").TryGetProperty("eyeColour", out _), "Character appearance schema should include eyeColour.");
|
||||||
|
Assert(ArrayItem(properties.GetProperty("characterAppearance")).GetProperty("properties").TryGetProperty("height", out _), "Character appearance schema should include height.");
|
||||||
|
Assert(!properties.TryGetProperty("summary", out _), "Strict scene schema should not reintroduce duplicate factual scene summaries.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic()
|
||||||
|
{
|
||||||
|
var schemaText = JsonSerializer.Serialize(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
|
||||||
|
var client = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs"));
|
||||||
|
var forbidden = new[] { "Memory Tin", "Triumph TR6", "Mrs Patterson", "Maggie", "Beth", "Rob", "Rosie", "Victor", "Alpha Flame" };
|
||||||
|
|
||||||
|
foreach (var term in forbidden)
|
||||||
|
{
|
||||||
|
Assert(!schemaText.Contains(term, StringComparison.OrdinalIgnoreCase), $"Strict schema must not contain fixture term {term}.");
|
||||||
|
Assert(!client.Contains(term, StringComparison.OrdinalIgnoreCase), $"OpenAI client path must not contain fixture term {term}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static SceneIntelligenceScene RepresentativeStructuredScene()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
SchemaVersion = "1.0",
|
||||||
|
SceneReference = new SceneIntelligenceSceneReference
|
||||||
|
{
|
||||||
|
ProjectId = 12,
|
||||||
|
BookId = 34,
|
||||||
|
ChapterId = 56,
|
||||||
|
SceneId = 78,
|
||||||
|
ChapterNumber = 2,
|
||||||
|
SceneNumber = 4,
|
||||||
|
SourceLabel = "Chapter 2, Scene 4"
|
||||||
|
},
|
||||||
|
ScenePurpose = new SceneIntelligenceScenePurpose { ObservedFunction = "Reveals a missing archive key.", Confidence = 0.86m },
|
||||||
|
PointOfView = new SceneIntelligencePointOfView { CharacterName = "Iris Vale", NarrativeMode = "first person", Confidence = 0.92m, Evidence = "PlotDirector scene metadata supplies Iris Vale as POV." },
|
||||||
|
Setting = new SceneIntelligenceSetting { TimeOfDay = "night", DateOrTimeReference = null, LocationName = "Clocktower Archive", LocationType = "named", GenericRoomType = null, ParentLocationHint = null, Confidence = 0.88m },
|
||||||
|
Characters = [new SceneIntelligenceCharacter { Name = "Iris Vale", RoleInScene = "pointOfView", MentionedOnly = false, Aliases = ["Iris"], Actions = ["finds the archive key"], Confidence = 0.91m, Notes = null }],
|
||||||
|
CharacterAppearance = [new SceneIntelligenceCharacterAppearance { CanonicalName = "Iris Vale", Aliases = ["Iris"], Title = null, Presentation = "feminine", ExplicitAge = null, InferredAgeBand = "adult", HairColour = null, HairLength = null, HairStyle = null, EyeColour = null, FacialHair = null, Glasses = null, Height = null, Build = null, DistinctiveFeatures = [], Clothing = "raincoat", RoleOrOccupation = "archivist", RelationshipAgeEvidence = null, PronounEvidence = "she", Evidence = "Iris tightened her raincoat.", Confidence = 0.72m, EvidenceKind = "explicit" }],
|
||||||
|
Locations = [new SceneIntelligenceLocation { Name = "Clocktower Archive", LocationType = "named", GenericRoomType = null, ParentLocationHint = null, PresentInScene = true, MentionedOnly = false, Confidence = 0.9m, Notes = null }],
|
||||||
|
Assets = [new SceneIntelligenceAsset { Name = "brass archive key", AssetType = "key", Status = "found", OwnerOrHolder = "Iris Vale", MentionedOnly = false, Confidence = 0.87m, Notes = null }],
|
||||||
|
Relationships = [new SceneIntelligenceRelationship { CharacterA = "Iris Vale", CharacterB = "Jon Calder", RelationshipSignal = "trust", Evidence = "Iris trusts Jon with the key.", Confidence = 0.76m }],
|
||||||
|
KnowledgeChanges = [new SceneIntelligenceKnowledgeChange { RecipientCharacter = "Iris Vale", KnowledgeItem = "The archive key opens the sealed west cabinet.", ChangeType = "Discovers", SourceCharacter = null, SourceType = "document", Evidence = "The tag reads west cabinet.", Confidence = 0.83m }],
|
||||||
|
TimelineClues = [new SceneIntelligenceTimelineClue { Clue = "after midnight", RelativeOrder = "after the public lecture", AbsoluteDate = null, Evidence = "The clock had already struck twelve.", Confidence = 0.8m }],
|
||||||
|
NarrativeArcs = [new SceneIntelligenceNarrativeArc { Title = "The sealed cabinet", Description = "Iris moves closer to opening the sealed cabinet.", ArcType = "mystery", Scale = "secondary", ChangeType = "Developed", Significance = "moderate", ContinuityKey = "sealed cabinet", ParentArcTitle = null, MatchingExistingPlotLineId = null, MatchingExistingThreadId = null, Evidence = "The key is labelled for the west cabinet.", Confidence = 0.82m }],
|
||||||
|
QuestionsRaised = [new SceneIntelligenceQuestionRaised { Question = "What is hidden in the sealed cabinet?", Scope = "asset", Evidence = "The cabinet has been sealed for years.", Confidence = 0.78m }],
|
||||||
|
QuestionsAnswered = [new SceneIntelligenceQuestionAnswered { Question = "Where is the archive key?", Answer = "Iris finds it behind the ledger shelf.", Evidence = "Iris finds the key behind the ledger shelf.", Confidence = 0.9m }],
|
||||||
|
Observations = [new SceneIntelligenceObservation { ObservationType = "AssetInteraction", SubjectEntityType = "Character", SubjectName = "Iris Vale", ObjectEntityType = "Asset", ObjectName = "brass archive key", Predicate = "finds", Description = "Iris finds the brass archive key.", Evidence = "Iris finds the key behind the ledger shelf.", Confidence = 0.9m }],
|
||||||
|
MetricValues = [new SceneIntelligenceMetricValue { Key = "tension", Score = 7, Confidence = 0.74m }],
|
||||||
|
SourceLimits = new SceneIntelligenceSourceLimits { ContainsAmbiguity = false, AmbiguityNotes = [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
static JsonElement SceneSchemaRoot()
|
||||||
|
=> JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
|
||||||
|
|
||||||
|
static JsonElement ArrayItem(JsonElement arraySchema)
|
||||||
|
=> arraySchema.GetProperty("items");
|
||||||
|
|
||||||
|
static void AssertConfidence(JsonElement confidenceSchema, string path)
|
||||||
|
{
|
||||||
|
Assert(confidenceSchema.GetProperty("type").GetString() == "number", $"{path} should be a number.");
|
||||||
|
Assert(confidenceSchema.GetProperty("minimum").GetInt32() == 0, $"{path} should have minimum 0.");
|
||||||
|
Assert(confidenceSchema.GetProperty("maximum").GetInt32() == 1, $"{path} should have maximum 1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AssertString(JsonElement stringSchema, string path, bool nullable = false)
|
||||||
|
{
|
||||||
|
if (!nullable)
|
||||||
|
{
|
||||||
|
Assert(stringSchema.GetProperty("type").GetString() == "string", $"{path} should be a string.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var types = stringSchema.GetProperty("type").EnumerateArray().Select(item => item.GetString()).ToHashSet(StringComparer.Ordinal);
|
||||||
|
Assert(types.SetEquals(["string", "null"]), $"{path} should be nullable string.");
|
||||||
|
}
|
||||||
|
|
||||||
static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs()
|
static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
|||||||
@ -162,7 +162,7 @@ Use the `configuredMetrics` array supplied in `sceneContext`. Do not invent a se
|
|||||||
|
|
||||||
For each configured metric:
|
For each configured metric:
|
||||||
|
|
||||||
- use the exact `key` supplied in `sceneContext` as the property name in `metrics`;
|
- use the exact `key` supplied in `sceneContext` as the `key` value in `metricValues`;
|
||||||
- use the supplied `name`, `description`, `minValue`, `maxValue` and `defaultValue` to understand the metric;
|
- use the supplied `name`, `description`, `minValue`, `maxValue` and `defaultValue` to understand the metric;
|
||||||
- score only what occurs in this scene, not imagined future consequences;
|
- score only what occurs in this scene, not imagined future consequences;
|
||||||
- treat the scale comparatively across scenes in the book;
|
- treat the scale comparatively across scenes in the book;
|
||||||
@ -172,15 +172,11 @@ For each configured metric:
|
|||||||
- use the high range for dominant/intense presence;
|
- use the high range for dominant/intense presence;
|
||||||
- use the configured maximum only for exceptional/extreme presence.
|
- use the configured maximum only for exceptional/extreme presence.
|
||||||
|
|
||||||
If no configured metrics are supplied, return an empty `metrics` object.
|
If no configured metrics are supplied, return an empty `metricValues` array.
|
||||||
|
|
||||||
Each metric must be an object:
|
Each metric value must contain `key`, `score` and `confidence`.
|
||||||
|
|
||||||
```json
|
Always emit every configured metric. If a metric is barely present, use the configured minimum value with an appropriate confidence. Do not omit configured metric values.
|
||||||
{ "score": 8, "confidence": 0.75 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Always emit every configured metric. If a metric is barely present, use the configured minimum value with an appropriate confidence. Do not omit configured metric objects.
|
|
||||||
|
|
||||||
## Character Reporting Rules
|
## Character Reporting Rules
|
||||||
|
|
||||||
@ -217,7 +213,7 @@ Mentioned-only characters matter because later stages need to know who is refere
|
|||||||
|
|
||||||
Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`.
|
Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`.
|
||||||
|
|
||||||
For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `facialHair`, `glasses`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`.
|
For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `eyeColour`, `facialHair`, `glasses`, `height`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@ -581,192 +577,11 @@ Incorrect:
|
|||||||
|
|
||||||
## JSON Output Requirements
|
## JSON Output Requirements
|
||||||
|
|
||||||
Return exactly one JSON object matching this shape:
|
Return exactly one JSON object that satisfies the API-provided `scene_intelligence_v1` structured-output schema.
|
||||||
|
|
||||||
```json
|
Use `[]` for any array with no supported items. Never output placeholder objects with empty required strings.
|
||||||
{
|
|
||||||
"schemaVersion": "1.0",
|
|
||||||
"sceneReference": {
|
|
||||||
"projectId": null,
|
|
||||||
"bookId": null,
|
|
||||||
"chapterId": null,
|
|
||||||
"sceneId": null,
|
|
||||||
"chapterNumber": null,
|
|
||||||
"sceneNumber": null,
|
|
||||||
"sourceLabel": ""
|
|
||||||
},
|
|
||||||
"scenePurpose": {
|
|
||||||
"observedFunction": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
},
|
|
||||||
"pointOfView": {
|
|
||||||
"characterName": null,
|
|
||||||
"narrativeMode": null,
|
|
||||||
"confidence": 0.0,
|
|
||||||
"evidence": null
|
|
||||||
},
|
|
||||||
"setting": {
|
|
||||||
"timeOfDay": null,
|
|
||||||
"dateOrTimeReference": null,
|
|
||||||
"locationName": null,
|
|
||||||
"locationType": "unclear",
|
|
||||||
"genericRoomType": null,
|
|
||||||
"parentLocationHint": null,
|
|
||||||
"confidence": 0.0
|
|
||||||
},
|
|
||||||
"characters": [
|
|
||||||
{
|
|
||||||
"name": "",
|
|
||||||
"roleInScene": "unclear",
|
|
||||||
"mentionedOnly": false,
|
|
||||||
"aliases": [],
|
|
||||||
"actions": [],
|
|
||||||
"confidence": 0.0,
|
|
||||||
"notes": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"characterAppearance": [
|
|
||||||
{
|
|
||||||
"canonicalName": "",
|
|
||||||
"aliases": [],
|
|
||||||
"title": null,
|
|
||||||
"presentation": null,
|
|
||||||
"explicitAge": null,
|
|
||||||
"inferredAgeBand": null,
|
|
||||||
"hairColour": null,
|
|
||||||
"hairLength": null,
|
|
||||||
"hairStyle": null,
|
|
||||||
"facialHair": null,
|
|
||||||
"glasses": null,
|
|
||||||
"build": null,
|
|
||||||
"distinctiveFeatures": [],
|
|
||||||
"clothing": null,
|
|
||||||
"roleOrOccupation": null,
|
|
||||||
"relationshipAgeEvidence": null,
|
|
||||||
"pronounEvidence": null,
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"evidenceKind": "explicit"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"locations": [
|
|
||||||
{
|
|
||||||
"name": "",
|
|
||||||
"locationType": "unclear",
|
|
||||||
"genericRoomType": null,
|
|
||||||
"parentLocationHint": null,
|
|
||||||
"presentInScene": false,
|
|
||||||
"mentionedOnly": false,
|
|
||||||
"confidence": 0.0,
|
|
||||||
"notes": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "",
|
|
||||||
"assetType": null,
|
|
||||||
"status": null,
|
|
||||||
"ownerOrHolder": null,
|
|
||||||
"mentionedOnly": false,
|
|
||||||
"confidence": 0.0,
|
|
||||||
"notes": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"relationships": [
|
|
||||||
{
|
|
||||||
"characterA": "",
|
|
||||||
"characterB": "",
|
|
||||||
"relationshipSignal": "",
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"knowledgeChanges": [
|
|
||||||
{
|
|
||||||
"recipientCharacter": "",
|
|
||||||
"knowledgeItem": "",
|
|
||||||
"changeType": "Learns",
|
|
||||||
"sourceCharacter": null,
|
|
||||||
"sourceType": null,
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timelineClues": [
|
|
||||||
{
|
|
||||||
"clue": "",
|
|
||||||
"relativeOrder": null,
|
|
||||||
"absoluteDate": null,
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"narrativeArcs": [
|
|
||||||
{
|
|
||||||
"title": "",
|
|
||||||
"description": "",
|
|
||||||
"arcType": "other",
|
|
||||||
"scale": "major",
|
|
||||||
"changeType": "Introduced",
|
|
||||||
"significance": "major",
|
|
||||||
"continuityKey": "",
|
|
||||||
"parentArcTitle": null,
|
|
||||||
"matchingExistingPlotLineId": null,
|
|
||||||
"matchingExistingThreadId": null,
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"questionsRaised": [
|
|
||||||
{
|
|
||||||
"question": "",
|
|
||||||
"scope": "plot",
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"questionsAnswered": [
|
|
||||||
{
|
|
||||||
"question": "",
|
|
||||||
"answer": "",
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"observations": [
|
|
||||||
{
|
|
||||||
"observationType": "CharacterAction",
|
|
||||||
"subjectEntityType": "Character",
|
|
||||||
"subjectName": "",
|
|
||||||
"objectEntityType": null,
|
|
||||||
"objectName": null,
|
|
||||||
"predicate": "observes",
|
|
||||||
"description": "",
|
|
||||||
"evidence": "",
|
|
||||||
"confidence": 0.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metrics": {
|
|
||||||
"configuredMetricKey": { "score": 1, "confidence": 0.0 }
|
|
||||||
},
|
|
||||||
"sourceLimits": {
|
|
||||||
"containsAmbiguity": false,
|
|
||||||
"ambiguityNotes": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The example arrays above show object shape. In the actual response, use `[]` for any array with no supported items. Never output placeholder objects with empty required strings.
|
All arrays must be present. Every object in an array must include every schema property for that object type. Every required object must be present. Unknown scalar values must be `null` only where the schema permits null. Unknown lists must be `[]`. Do not omit properties.
|
||||||
|
|
||||||
All arrays must be present. Every object in an array must include every property shown for that object type. Every required object must be present. Unknown scalar values must be `null` only where the schema permits null. Unknown lists must be `[]`. Do not omit properties.
|
|
||||||
|
|
||||||
Use valid JSON:
|
|
||||||
|
|
||||||
- double quotes for property names and strings;
|
|
||||||
- no trailing commas;
|
|
||||||
- no comments;
|
|
||||||
- no Markdown;
|
|
||||||
- no prose outside JSON.
|
|
||||||
|
|
||||||
## Runtime Data Insertion
|
## Runtime Data Insertion
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ public sealed class SceneIntelligenceScene
|
|||||||
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
|
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
|
||||||
public List<SceneIntelligenceObservation>? Observations { get; init; }
|
public List<SceneIntelligenceObservation>? Observations { get; init; }
|
||||||
public Dictionary<string, SceneIntelligenceMetric>? Metrics { get; init; }
|
public Dictionary<string, SceneIntelligenceMetric>? Metrics { get; init; }
|
||||||
|
public List<SceneIntelligenceMetricValue>? MetricValues { get; init; }
|
||||||
public SceneIntelligenceSourceLimits? SourceLimits { get; init; }
|
public SceneIntelligenceSourceLimits? SourceLimits { get; init; }
|
||||||
|
|
||||||
[JsonExtensionData]
|
[JsonExtensionData]
|
||||||
@ -263,6 +264,16 @@ public sealed class SceneIntelligenceMetric
|
|||||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SceneIntelligenceMetricValue
|
||||||
|
{
|
||||||
|
public string? Key { get; init; }
|
||||||
|
public int? Score { get; init; }
|
||||||
|
public decimal? Confidence { get; init; }
|
||||||
|
|
||||||
|
[JsonExtensionData]
|
||||||
|
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class SceneIntelligenceSourceLimits
|
public sealed class SceneIntelligenceSourceLimits
|
||||||
{
|
{
|
||||||
public bool? ContainsAmbiguity { get; init; }
|
public bool? ContainsAmbiguity { get; init; }
|
||||||
|
|||||||
@ -236,3 +236,10 @@ public sealed class StoryIntelligenceClientResult
|
|||||||
public int? OutputTokens { get; init; }
|
public int? OutputTokens { get; init; }
|
||||||
public decimal? EstimatedCost { get; init; }
|
public decimal? EstimatedCost { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceResponseContract
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public object Schema { get; init; } = new();
|
||||||
|
public bool Strict { get; init; } = true;
|
||||||
|
}
|
||||||
|
|||||||
@ -236,7 +236,8 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
scenePromptVersion,
|
scenePromptVersion,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
stageModels.SceneIntelligenceModel,
|
stageModels.SceneIntelligenceModel,
|
||||||
SceneIntelligenceMaxOutputTokens());
|
SceneIntelligenceMaxOutputTokens(),
|
||||||
|
StoryIntelligenceSceneStructuredOutputSchema.Contract);
|
||||||
openAiStopwatch.Stop();
|
openAiStopwatch.Stop();
|
||||||
totals.Add(sceneClientResult, pricing);
|
totals.Add(sceneClientResult, pricing);
|
||||||
var parseStopwatch = Stopwatch.StartNew();
|
var parseStopwatch = Stopwatch.StartNew();
|
||||||
|
|||||||
@ -462,14 +462,15 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
|
|
||||||
private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
|
private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
|
||||||
{
|
{
|
||||||
if (parsed.Metrics is null || parsed.Metrics.Count == 0)
|
var metrics = ReadMetrics(parsed);
|
||||||
|
if (metrics.Count == 0)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var values = new List<SceneMetricValue>();
|
var values = new List<SceneMetricValue>();
|
||||||
var importedMetricTypeIds = new HashSet<int>();
|
var importedMetricTypeIds = new HashSet<int>();
|
||||||
foreach (var (name, metric) in parsed.Metrics)
|
foreach (var (name, metric) in metrics)
|
||||||
{
|
{
|
||||||
if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType))
|
if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType))
|
||||||
{
|
{
|
||||||
@ -493,6 +494,36 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, SceneIntelligenceMetric> ReadMetrics(SceneIntelligenceScene parsed)
|
||||||
|
{
|
||||||
|
if (parsed.Metrics is { Count: > 0 })
|
||||||
|
{
|
||||||
|
return parsed.Metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.MetricValues is null || parsed.MetricValues.Count == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, SceneIntelligenceMetric>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
var metrics = new Dictionary<string, SceneIntelligenceMetric>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var metric in parsed.MetricValues)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(metric.Key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics[metric.Key.Trim()] = new SceneIntelligenceMetric
|
||||||
|
{
|
||||||
|
Score = metric.Score,
|
||||||
|
Confidence = metric.Confidence
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap)
|
private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap)
|
||||||
{
|
{
|
||||||
var values = ReadPurposeTexts(parsed).Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
var values = ReadPurposeTexts(parsed).Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|||||||
@ -33,7 +33,8 @@ public interface IStoryIntelligenceClient
|
|||||||
string promptVersion,
|
string promptVersion,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? modelOverride = null,
|
string? modelOverride = null,
|
||||||
int? maxOutputTokensOverride = null);
|
int? maxOutputTokensOverride = null,
|
||||||
|
StoryIntelligenceResponseContract? responseContract = null);
|
||||||
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
|
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,7 +169,8 @@ public sealed class StoryIntelligenceClient(
|
|||||||
string promptVersion,
|
string promptVersion,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? modelOverride = null,
|
string? modelOverride = null,
|
||||||
int? maxOutputTokensOverride = null)
|
int? maxOutputTokensOverride = null,
|
||||||
|
StoryIntelligenceResponseContract? responseContract = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(settings.ApiKey))
|
if (string.IsNullOrWhiteSpace(settings.ApiKey))
|
||||||
{
|
{
|
||||||
@ -195,6 +197,20 @@ public sealed class StoryIntelligenceClient(
|
|||||||
maxOutputTokens = settings.MaxOutputTokens;
|
maxOutputTokens = settings.MaxOutputTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var textOptions = responseContract is null
|
||||||
|
? new Dictionary<string, object> { ["verbosity"] = "low" }
|
||||||
|
: new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["verbosity"] = "low",
|
||||||
|
["format"] = new
|
||||||
|
{
|
||||||
|
type = "json_schema",
|
||||||
|
name = responseContract.Name,
|
||||||
|
strict = responseContract.Strict,
|
||||||
|
schema = responseContract.Schema
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var requestBody = JsonSerializer.Serialize(new
|
var requestBody = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
model = model,
|
model = model,
|
||||||
@ -205,16 +221,14 @@ public sealed class StoryIntelligenceClient(
|
|||||||
{
|
{
|
||||||
effort = "minimal"
|
effort = "minimal"
|
||||||
},
|
},
|
||||||
text = new
|
text = textOptions
|
||||||
{
|
|
||||||
verbosity = "low"
|
|
||||||
}
|
|
||||||
}, JsonOptions);
|
}, JsonOptions);
|
||||||
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (responseText, retryCount) = await SendWithRetryAsync(requestBody, cancellationToken);
|
var (responseText, retryCount) = await SendWithRetryAsync(requestBody, cancellationToken);
|
||||||
|
EnsureUsableResponse(responseText, responseContract);
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
|
"Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
|
||||||
@ -358,6 +372,33 @@ public sealed class StoryIntelligenceClient(
|
|||||||
return (null, null);
|
return (null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void EnsureUsableResponse(string responseText, StoryIntelligenceResponseContract? responseContract)
|
||||||
|
{
|
||||||
|
if (responseContract is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(responseText, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException("OpenAI structured response envelope was empty.");
|
||||||
|
if (!string.IsNullOrWhiteSpace(response.Status)
|
||||||
|
&& !string.Equals(response.Status, "completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var detail = response.IncompleteDetails?.Reason ?? response.Error?.Message ?? response.Status;
|
||||||
|
throw new InvalidOperationException($"OpenAI structured response did not complete: {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var refusal = response.Output?
|
||||||
|
.SelectMany(item => item.Content ?? [])
|
||||||
|
.FirstOrDefault(content => string.Equals(content.Type, "refusal", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !string.IsNullOrWhiteSpace(content.Refusal))
|
||||||
|
?.Refusal;
|
||||||
|
if (!string.IsNullOrWhiteSpace(refusal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OpenAI structured response was refused: {TrimForLog(refusal)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceDiagnosticsService(
|
public sealed class StoryIntelligenceDiagnosticsService(
|
||||||
@ -447,7 +488,8 @@ public sealed class StoryIntelligenceDryRunService(
|
|||||||
completedPrompt,
|
completedPrompt,
|
||||||
promptVersion,
|
promptVersion,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
clientStatus.SceneIntelligenceModel);
|
clientStatus.SceneIntelligenceModel,
|
||||||
|
responseContract: StoryIntelligenceSceneStructuredOutputSchema.Contract);
|
||||||
var sceneJson = ExtractSceneJson(result.RawResponseText);
|
var sceneJson = ExtractSceneJson(result.RawResponseText);
|
||||||
var parsedScene = DeserializeScene(sceneJson);
|
var parsedScene = DeserializeScene(sceneJson);
|
||||||
var validation = validator.Validate(parsedScene);
|
var validation = validator.Validate(parsedScene);
|
||||||
@ -742,8 +784,14 @@ public sealed class ChapterStructureDryRunService(
|
|||||||
|
|
||||||
internal sealed class OpenAIResponseEnvelope
|
internal sealed class OpenAIResponseEnvelope
|
||||||
{
|
{
|
||||||
|
public string? Status { get; init; }
|
||||||
public List<OpenAIResponseOutputItem>? Output { get; init; }
|
public List<OpenAIResponseOutputItem>? Output { get; init; }
|
||||||
public OpenAIResponseUsage? Usage { get; init; }
|
public OpenAIResponseUsage? Usage { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("incomplete_details")]
|
||||||
|
public OpenAIResponseIncompleteDetails? IncompleteDetails { get; init; }
|
||||||
|
|
||||||
|
public OpenAIResponseError? Error { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class OpenAIResponseOutputItem
|
internal sealed class OpenAIResponseOutputItem
|
||||||
@ -755,6 +803,7 @@ internal sealed class OpenAIResponseContentItem
|
|||||||
{
|
{
|
||||||
public string? Type { get; init; }
|
public string? Type { get; init; }
|
||||||
public string? Text { get; init; }
|
public string? Text { get; init; }
|
||||||
|
public string? Refusal { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class OpenAIResponseUsage
|
internal sealed class OpenAIResponseUsage
|
||||||
@ -766,6 +815,16 @@ internal sealed class OpenAIResponseUsage
|
|||||||
public int? OutputTokens { get; init; }
|
public int? OutputTokens { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class OpenAIResponseIncompleteDetails
|
||||||
|
{
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OpenAIResponseError
|
||||||
|
{
|
||||||
|
public string? Message { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceClientConfigurationStatus
|
public sealed class StoryIntelligenceClientConfigurationStatus
|
||||||
{
|
{
|
||||||
public bool ApiKeyConfigured { get; init; }
|
public bool ApiKeyConfigured { get; init; }
|
||||||
|
|||||||
@ -0,0 +1,210 @@
|
|||||||
|
using PlotLine.Models;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public static class StoryIntelligenceSceneStructuredOutputSchema
|
||||||
|
{
|
||||||
|
public const string ContractName = "scene_intelligence_v1";
|
||||||
|
|
||||||
|
public static StoryIntelligenceResponseContract Contract { get; } = new()
|
||||||
|
{
|
||||||
|
Name = ContractName,
|
||||||
|
Strict = true,
|
||||||
|
Schema = BuildSchema()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, object> BuildSchema()
|
||||||
|
{
|
||||||
|
var sceneReference = Obj(Props(
|
||||||
|
("projectId", NullableInteger()),
|
||||||
|
("bookId", NullableInteger()),
|
||||||
|
("chapterId", NullableInteger()),
|
||||||
|
("sceneId", NullableInteger()),
|
||||||
|
("chapterNumber", NullableNumber()),
|
||||||
|
("sceneNumber", NullableNumber()),
|
||||||
|
("sourceLabel", Str())));
|
||||||
|
var purpose = Obj(Props(("observedFunction", Str()), ("confidence", Confidence())));
|
||||||
|
var pointOfView = Obj(Props(
|
||||||
|
("characterName", NullableString()),
|
||||||
|
("narrativeMode", NullableString()),
|
||||||
|
("confidence", Confidence()),
|
||||||
|
("evidence", NullableString())));
|
||||||
|
var setting = Obj(Props(
|
||||||
|
("timeOfDay", NullableString()),
|
||||||
|
("dateOrTimeReference", NullableString()),
|
||||||
|
("locationName", NullableString()),
|
||||||
|
("locationType", Enum("named", "generic", "unclear")),
|
||||||
|
("genericRoomType", NullableString()),
|
||||||
|
("parentLocationHint", NullableString()),
|
||||||
|
("confidence", Confidence())));
|
||||||
|
|
||||||
|
var characters = Arr(Obj(Props(
|
||||||
|
("name", Str()),
|
||||||
|
("roleInScene", Enum("present", "pointOfView", "speaker", "mentioned", "unclear")),
|
||||||
|
("mentionedOnly", Bool()),
|
||||||
|
("aliases", Arr(Str())),
|
||||||
|
("actions", Arr(Str())),
|
||||||
|
("confidence", Confidence()),
|
||||||
|
("notes", NullableString()))));
|
||||||
|
var characterAppearance = Arr(Obj(Props(
|
||||||
|
("canonicalName", Str()),
|
||||||
|
("aliases", Arr(Str())),
|
||||||
|
("title", NullableString()),
|
||||||
|
("presentation", NullableString()),
|
||||||
|
("explicitAge", NullableString()),
|
||||||
|
("inferredAgeBand", NullableString()),
|
||||||
|
("hairColour", NullableString()),
|
||||||
|
("hairLength", NullableString()),
|
||||||
|
("hairStyle", NullableString()),
|
||||||
|
("eyeColour", NullableString()),
|
||||||
|
("facialHair", NullableString()),
|
||||||
|
("glasses", NullableString()),
|
||||||
|
("height", NullableString()),
|
||||||
|
("build", NullableString()),
|
||||||
|
("distinctiveFeatures", Arr(Str())),
|
||||||
|
("clothing", NullableString()),
|
||||||
|
("roleOrOccupation", NullableString()),
|
||||||
|
("relationshipAgeEvidence", NullableString()),
|
||||||
|
("pronounEvidence", NullableString()),
|
||||||
|
("evidence", Str()),
|
||||||
|
("confidence", Confidence()),
|
||||||
|
("evidenceKind", Enum("explicit", "inferred")))));
|
||||||
|
var locations = Arr(Obj(Props(
|
||||||
|
("name", Str()),
|
||||||
|
("locationType", Enum("named", "generic", "unclear")),
|
||||||
|
("genericRoomType", NullableString()),
|
||||||
|
("parentLocationHint", NullableString()),
|
||||||
|
("presentInScene", Bool()),
|
||||||
|
("mentionedOnly", Bool()),
|
||||||
|
("confidence", Confidence()),
|
||||||
|
("notes", NullableString()))));
|
||||||
|
var assets = Arr(Obj(Props(
|
||||||
|
("name", Str()),
|
||||||
|
("assetType", NullableString()),
|
||||||
|
("status", NullableString()),
|
||||||
|
("ownerOrHolder", NullableString()),
|
||||||
|
("mentionedOnly", Bool()),
|
||||||
|
("confidence", Confidence()),
|
||||||
|
("notes", NullableString()))));
|
||||||
|
var relationships = Arr(Obj(Props(
|
||||||
|
("characterA", Str()),
|
||||||
|
("characterB", Str()),
|
||||||
|
("relationshipSignal", Enum("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation")),
|
||||||
|
("evidence", Str()),
|
||||||
|
("confidence", Confidence()))));
|
||||||
|
var knowledgeChanges = Arr(Obj(Props(
|
||||||
|
("recipientCharacter", Str()),
|
||||||
|
("knowledgeItem", Str()),
|
||||||
|
("changeType", Enum("Learns", "Realises", "IsTold", "Discovers", "Confirms", "Suspects", "Misunderstands")),
|
||||||
|
("sourceCharacter", NullableString()),
|
||||||
|
("sourceType", NullableString()),
|
||||||
|
("evidence", Str()),
|
||||||
|
("confidence", Confidence()))));
|
||||||
|
var timelineClues = Arr(Obj(Props(
|
||||||
|
("clue", Str()),
|
||||||
|
("relativeOrder", NullableString()),
|
||||||
|
("absoluteDate", NullableString()),
|
||||||
|
("evidence", Str()),
|
||||||
|
("confidence", Confidence()))));
|
||||||
|
var narrativeArcs = Arr(Obj(Props(
|
||||||
|
("title", Str()),
|
||||||
|
("description", Str()),
|
||||||
|
("arcType", Str()),
|
||||||
|
("scale", Enum("major", "secondary", "thread")),
|
||||||
|
("changeType", Enum("Introduced", "Developed", "Complicated", "Revealed", "Escalated", "Resolved", "Reopened", "Mentioned")),
|
||||||
|
("significance", Enum("major", "moderate", "minor")),
|
||||||
|
("continuityKey", Str()),
|
||||||
|
("parentArcTitle", NullableString()),
|
||||||
|
("matchingExistingPlotLineId", NullableInteger()),
|
||||||
|
("matchingExistingThreadId", NullableInteger()),
|
||||||
|
("evidence", Str()),
|
||||||
|
("confidence", Confidence()))));
|
||||||
|
var questionsRaised = Arr(Obj(Props(("question", Str()), ("scope", Str()), ("evidence", Str()), ("confidence", Confidence()))));
|
||||||
|
var questionsAnswered = Arr(Obj(Props(("question", Str()), ("answer", Str()), ("evidence", Str()), ("confidence", Confidence()))));
|
||||||
|
var observations = Arr(Obj(Props(
|
||||||
|
("observationType", Enum("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal")),
|
||||||
|
("subjectEntityType", Enum("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown")),
|
||||||
|
("subjectName", Str()),
|
||||||
|
("objectEntityType", NullableEnum("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown")),
|
||||||
|
("objectName", NullableString()),
|
||||||
|
("predicate", Str()),
|
||||||
|
("description", Str()),
|
||||||
|
("evidence", Str()),
|
||||||
|
("confidence", Confidence()))));
|
||||||
|
var metricValues = Arr(Obj(Props(
|
||||||
|
("key", Str("Configured metric key from sceneContext.configuredMetrics.")),
|
||||||
|
("score", Integer()),
|
||||||
|
("confidence", Confidence()))));
|
||||||
|
var sourceLimits = Obj(Props(("containsAmbiguity", Bool()), ("ambiguityNotes", Arr(Str()))));
|
||||||
|
|
||||||
|
return Obj(Props(
|
||||||
|
("schemaVersion", Str("Scene Intelligence schema version. Use 1.0.")),
|
||||||
|
("sceneReference", sceneReference),
|
||||||
|
("scenePurpose", purpose),
|
||||||
|
("pointOfView", pointOfView),
|
||||||
|
("setting", setting),
|
||||||
|
("characters", characters),
|
||||||
|
("characterAppearance", characterAppearance),
|
||||||
|
("locations", locations),
|
||||||
|
("assets", assets),
|
||||||
|
("relationships", relationships),
|
||||||
|
("knowledgeChanges", knowledgeChanges),
|
||||||
|
("timelineClues", timelineClues),
|
||||||
|
("narrativeArcs", narrativeArcs),
|
||||||
|
("questionsRaised", questionsRaised),
|
||||||
|
("questionsAnswered", questionsAnswered),
|
||||||
|
("observations", observations),
|
||||||
|
("metricValues", metricValues),
|
||||||
|
("sourceLimits", sourceLimits)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Obj(Dictionary<string, object> properties)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
["type"] = "object",
|
||||||
|
["properties"] = properties,
|
||||||
|
["required"] = properties.Keys.ToArray(),
|
||||||
|
["additionalProperties"] = false
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Arr(object items)
|
||||||
|
=> new() { ["type"] = "array", ["items"] = items };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Str(string? description = null)
|
||||||
|
{
|
||||||
|
var value = new Dictionary<string, object> { ["type"] = "string" };
|
||||||
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
value["description"] = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> NullableString()
|
||||||
|
=> new() { ["type"] = new[] { "string", "null" } };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Integer()
|
||||||
|
=> new() { ["type"] = "integer" };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> NullableInteger()
|
||||||
|
=> new() { ["type"] = new[] { "integer", "null" } };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> NullableNumber()
|
||||||
|
=> new() { ["type"] = new[] { "number", "null" } };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Bool()
|
||||||
|
=> new() { ["type"] = "boolean" };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Confidence()
|
||||||
|
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Enum(params string[] values)
|
||||||
|
=> new() { ["type"] = "string", ["enum"] = values };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> NullableEnum(params string[] values)
|
||||||
|
=> new() { ["type"] = new[] { "string", "null" }, ["enum"] = values.Cast<string?>().Append(null).ToArray() };
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Props(params (string Name, object Schema)[] properties)
|
||||||
|
=> properties.ToDictionary(property => property.Name, property => property.Schema, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
@ -47,6 +47,7 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
|||||||
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
|
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
|
||||||
ValidateObservations(scene.Observations, result);
|
ValidateObservations(scene.Observations, result);
|
||||||
ValidateMetrics(scene.Metrics, result);
|
ValidateMetrics(scene.Metrics, result);
|
||||||
|
ValidateMetricValues(scene.MetricValues, result);
|
||||||
ValidateSourceLimits(scene.SourceLimits, result);
|
ValidateSourceLimits(scene.SourceLimits, result);
|
||||||
|
|
||||||
if (result.IsValid)
|
if (result.IsValid)
|
||||||
@ -84,7 +85,10 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
|||||||
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
|
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
|
||||||
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
|
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
|
||||||
RequiredArray(scene.Observations, "observations", result);
|
RequiredArray(scene.Observations, "observations", result);
|
||||||
RequiredObject(scene.Metrics, "metrics", result);
|
if (scene.Metrics is null && scene.MetricValues is null)
|
||||||
|
{
|
||||||
|
AddError(result, "metrics", "Metric output is required.", "Return the legacy metrics object or strict metricValues array.");
|
||||||
|
}
|
||||||
RequiredObject(scene.SourceLimits, "sourceLimits", result);
|
RequiredObject(scene.SourceLimits, "sourceLimits", result);
|
||||||
Unknown(scene.ExtensionData, "$", result);
|
Unknown(scene.ExtensionData, "$", result);
|
||||||
}
|
}
|
||||||
@ -442,6 +446,34 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateMetricValues(List<SceneIntelligenceMetricValue>? values, ValidationResult result)
|
||||||
|
{
|
||||||
|
if (values is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < values.Count; i++)
|
||||||
|
{
|
||||||
|
var item = values[i];
|
||||||
|
var path = $"metricValues[{i}]";
|
||||||
|
RequiredString(item.Key, $"{path}.key", result);
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.Key) && !seen.Add(item.Key.Trim()))
|
||||||
|
{
|
||||||
|
AddWarning(result, path, "Duplicate metric key found.", "Return one metric value per configured metric key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.Score.HasValue)
|
||||||
|
{
|
||||||
|
AddError(result, $"{path}.score", "Metric score is required.", "Return a numeric score using the configured metric scale.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Confidence(item.Confidence, $"{path}.confidence", result);
|
||||||
|
Unknown(item.ExtensionData, path, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ValidateSourceLimits(SceneIntelligenceSourceLimits? value, ValidationResult result)
|
private static void ValidateSourceLimits(SceneIntelligenceSourceLimits? value, ValidationResult result)
|
||||||
{
|
{
|
||||||
if (value is null)
|
if (value is null)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user