Compare commits

..

No commits in common. "0b63bd751e10cbe5fc30de275fe8171067a9ed9c" and "738c1d118f55f7cedcf7eb14114b3ed5ca043f78" have entirely different histories.

120 changed files with 667 additions and 11654 deletions

View File

@ -1,17 +0,0 @@
# Scene relationship review correction
The existing architecture already has the required canonical foundation:
- `CharacterRelationships` identifies participants, type, permanence, start/end scenes, initial state and reciprocity. Character A -> Character B is directional when reciprocity is false.
- `RelationshipEvents` records scene, state, intensity, awareness and description. Scene Inspector and character history already consume these events; relationship summaries derive current state from history.
- Stored scene results preserve relationship JSON. The persisted review candidate system supplies paging and independent author decisions.
The old Story Intelligence importer grouped every scene by an unordered character pair, promoted the strongest whole-book classification, forced reciprocity and awareness, converted confidence to intensity, and created filtered events only after the user accepted the pair. Review exposed neither event states nor direction. Additionally, substring matching treated distrust as friendly trust.
The corrected flow emits individual scene proposals with factual/state/development kind, scene state, direction, awareness, emotional intensity, development and evidence. Review permits editing these and reversing direction. Meaningful developments share an existing matching relationship track; the default changing-state track is Other, while factual ties use their factual type and permanence. Opposite directions and factual ties remain separate. Multiple developments within a scene retain separate identities. Repeated factual mentions yield only the earliest proposal.
Migration `197_SceneRelationshipDevelopments.sql` adds an optional provenance key to existing events and a transactional acceptance procedure. Retries cannot duplicate or overwrite an accepted event. New relationships have no fabricated initial emotional state. Accepting an earlier scene moves a newly established track's start backwards within the book. Existing relationships and events are preserved; no competing history tables are introduced. The existing event deletion/merge infrastructure remains applicable.
Pending legacy pair cards can be rebuilt with `tools/RefreshRelationshipReviews --apply`. The tool snapshots pending cards before replacement; the stored procedure preserves accepted/ignored decisions, including old pair decisions. Completed reviews remain completed. Old analysis lacks explicit direction/intensity; proposals are marked for direction review, unknown intensity stays empty, and no mutual feeling or awareness is assumed. This conversion uses stored evidence, not another AI pass. New analysis supplies the richer fields directly.
Validation covers scene review and acceptance with unrelated character fixtures, factual deduplication, independent directional identity, confidence/intensity separation, whole-page input validation, numeric chronology, SQL retry behaviour, distinct factual/directional tracks and preserved decisions. SQL regression fixtures roll back all data.

View File

@ -6,8 +6,8 @@ The add-in is deliberately small:
- The manifest adds one ribbon button: **Open PlotDirector Companion**.
- The task pane opens the PlotDirector-hosted `/word-companion` route.
- The manifest requests `ReadWriteDocument` so the companion can read Word paragraphs and write hidden PlotDirector anchors back into the manuscript after import.
- The companion only writes hidden PlotDirector anchors needed to reconnect manuscript paragraphs to PlotDirector scenes.
- The manifest requests `ReadDocument` so the companion can read Word paragraphs, selection context, and document body content for the manual document-structure scan.
- The companion does not request document write permissions.
## Development

View File

@ -22,9 +22,9 @@
<DefaultSettings>
<SourceLocation DefaultValue="{{taskpaneUrl}}"/>
</DefaultSettings>
<!-- ReadWriteDocument is required because the companion writes hidden PlotDirector anchors
back into the Word document after a manuscript import. -->
<Permissions>ReadWriteDocument</Permissions>
<!-- ReadDocument is required for the companion to read Word paragraphs, selection context,
and document body content. It does not grant document write access. -->
<Permissions>ReadDocument</Permissions>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
<Hosts>
<Host xsi:type="Document">

View File

@ -22,9 +22,9 @@
<DefaultSettings>
<SourceLocation DefaultValue="https://plotdirector.com/word-companion"/>
</DefaultSettings>
<!-- ReadWriteDocument is required because the companion writes hidden PlotDirector anchors
back into the Word document after a manuscript import. -->
<Permissions>ReadWriteDocument</Permissions>
<!-- ReadDocument is required for the companion to read Word paragraphs, selection context,
and document body content. It does not grant document write access. -->
<Permissions>ReadDocument</Permissions>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
<Hosts>
<Host xsi:type="Document">

View File

@ -1,22 +0,0 @@
# Timeline metric browser regressions
Run from this directory:
```sh
npm ci
npx playwright install chromium
npm test
```
Set `CHROMIUM_PATH` to use an existing Chromium executable. The tests load the production metric-chart initializer, repository CSS, and the same Chart.js version referenced by `_Layout.cshtml`. They use isolated DOM fixtures; no database, login, or running application is required. Keep the pinned Chart.js dependency in step with the layout's CDN version.
Coverage includes all four timeline views and three densities, one and three books, headers consuming different horizontal widths, unequal scene widths, timelines exceeding 30,000 CSS pixels, start/middle/end scrolling, viewport resizing, section collapse/expand, and device pixel ratios 1 and 2. Assertions compare every scene centre with its chart coordinate, and visible rendered point positions, with a tolerance below half a CSS pixel. Tooltips, metric toggles, point navigation, and drag-save scene identity are exercised; saves are mocked.
The chart derives coordinates from rendered scene rectangles. A sticky, viewport-sized canvas lives inside the full-width frame in the existing timeline scroll container. The custom Chart.js linear scale uses CSS pixel positions directly, including axis and frame padding, without scaling scene positions to the chart area. ResizeObserver and the timeline layout event invalidate measurements; scroll events only update the visible chart window.
When scene cards are collapsed after measurement, the last measured geometry remains available. If scene cards are disabled or initially collapsed, the chart retains the standalone ordinal layout until rendered scene geometry becomes available. Analytics and character charts retain their standalone layouts. Raw metric rows remain on their existing CSS grid; they are not part of this chart fix.
## Location drag-and-drop regressions
Run `npm run test:locations` (with `CHROMIUM_PATH` if using an existing Chromium).
This loads the full production site script and Bootstrap confirmation modal in an isolated location-tree fixture. AJAX responses are mocked. It checks parent assignment/reassignment, cancellation, cycle/no-op rejection, repeated dragging after tree replacement, scroll preservation, server-error display, and relationship modal/AJAX saves. Database behavior is covered separately by `PlotLine.Tests/Sql/LocationReparent.sql`, whose fixtures always roll back.

View File

@ -1,106 +0,0 @@
const { chromium } = require('playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const repo = path.resolve(__dirname, '../..');
(async () => {
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH });
try {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('https://plotdirector.test/**', route => route.fulfill({ contentType: 'text/html', body: '<html><body></body></html>' }));
await page.goto('https://plotdirector.test/Locations');
const layout = fs.readFileSync(path.join(repo, 'PlotLine/Views/Shared/_Layout.cshtml'), 'utf8');
const modalStart = layout.indexOf(' <div class="modal fade" id="plotlineConfirmModal"');
const modal = layout.slice(modalStart, layout.indexOf(' <script', modalStart));
const rows = [ ['1','Room','',''], ['2','Building','',''], ['3','Cupboard','1','Room'], ['4','Annex','2','Building'] ]
.map(([id,name,parent,parentName]) => `<div style="padding:24px;border:1px solid" draggable="true" data-drag-type="location" data-drag-id="${id}" data-drag-label="${name}" data-parent-location-id="${parent}" data-parent-location-name="${parentName}"><a href="/Locations/Details/${id}">${name}</a></div>`).join('');
await page.setContent(`<select data-location-drag-action><option value="parent">Change parent</option><option value="relationship">Create relationship</option></select>
<p data-location-move-error></p><p data-location-move-status></p>
<form action="/Locations/Reparent" method="post" data-location-reparent-form data-confirm-message="Change parent?" data-confirm-button-text="Move location" data-confirm-button-class="btn-primary">
<input type="hidden" name="projectId" value="1"><input type="hidden" name="locationId"><input type="hidden" name="parentLocationId"><input type="hidden" name="expectedParentLocationId">
</form>
<div class="modal fade" tabindex="-1" data-location-relationship-modal><div class="modal-dialog"><section class="modal-content d-none" data-drag-drop-panel><h2 data-drag-panel-title></h2><span data-drag-panel-kicker></span><p data-drag-panel-summary></p>
<p data-location-relationship-error></p><form action="/Locations/DropRelationship" data-drag-form="location-relationship"><input name="FromLocationID"><input name="ToLocationID"><button type="submit">Save relationship</button></form></section></div></div><div style="height:900px"></div><section data-location-tree>${rows}</section><div style="height:2000px"></div>${modal}`);
await page.addStyleTag({ path: path.join(repo,'PlotLine/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css') });
await page.addScriptTag({ path: path.join(repo,'PlotLine/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js') });
// Load the entire production script: an earlier initialization error can disable dragging.
await page.addScriptTag({ path: path.join(repo,'PlotLine/wwwroot/js/site.js') });
await page.evaluate(() => {
window.savedMoves = [];
window.relationshipSaves = 0;
window.failNextSave = false;
window.fetch = async (url, options) => {
if (window.failNextSave) {
window.failNextSave = false;
return new Response(JSON.stringify({error:'The parent has changed. Review the tree.'}), {status:400});
}
if (url.endsWith('/DropRelationship')) {
window.relationshipSaves++;
return new Response(JSON.stringify({message:'Saved'}));
}
const values = Object.fromEntries(options.body);
window.savedMoves.push(values);
const tree = document.querySelector('[data-location-tree]').cloneNode(true);
const source = tree.querySelector(`[data-drag-id="${values.locationId}"]`);
const target = tree.querySelector(`[data-drag-id="${values.parentLocationId}"]`);
source.dataset.parentLocationId = values.parentLocationId;
source.dataset.parentLocationName = target.dataset.dragLabel;
tree.append(source);
return new Response(tree.outerHTML, {headers:{'Content-Type':'text/html'}});
};
});
const drag = async (source, target) => page.locator(`[data-drag-id="${source}"]`).dragTo(page.locator(`[data-drag-id="${target}"]`));
const confirm = page.locator('#plotlineConfirmModal');
await drag(1,2);
await confirm.waitFor({state:'visible'});
assert.match(await confirm.innerText(), /from top level to “Building”/);
assert.equal(await page.evaluate(() => savedMoves.length),0);
await confirm.locator('[data-bs-dismiss="modal"]').last().click();
await confirm.waitFor({state:'hidden'});
await page.locator('.modal-backdrop').waitFor({state:'hidden'});
assert.equal(await page.evaluate(() => savedMoves.length),0);
await drag(1,2);
const scrollBeforeSave = await page.evaluate(() => window.scrollY);
await confirm.locator('[data-confirm-submit]').click();
await confirm.waitFor({state:'hidden'});
await page.locator('.modal-backdrop').waitFor({state:'hidden'});
await page.waitForFunction(() => document.querySelector('[data-location-move-status]').textContent.includes('moved'));
assert.ok(Math.abs(await page.evaluate(() => window.scrollY) - scrollBeforeSave) < 2, 'AJAX update must preserve scroll');
assert.deepEqual(await page.evaluate(() => savedMoves[0]), {projectId:'1',locationId:'1',parentLocationId:'2',expectedParentLocationId:''});
await drag(4,1);
await confirm.waitFor({state:'visible'});
assert.match(await confirm.innerText(), /from Building to “Room”/);
await confirm.locator('[data-confirm-submit]').click();
await confirm.waitFor({state:'hidden'});
await page.locator('.modal-backdrop').waitFor({state:'hidden'});
assert.equal(await page.evaluate(() => savedMoves[1].expectedParentLocationId),'2');
for (const [source,target] of [[1,1],[1,3],[1,2]]) {
await drag(source,target);
assert.equal(await confirm.isVisible(),false);
assert.equal(await page.evaluate(() => savedMoves.length),2);
}
// Server validation failures leave the existing tree intact and explain the error.
await page.evaluate(() => window.failNextSave = true);
await drag(4,2);
await confirm.locator('[data-confirm-submit]').click();
await confirm.waitFor({state:'hidden'});
await page.locator('.modal-backdrop').waitFor({state:'hidden'});
assert.match(await page.locator('[data-location-move-error]').innerText(), /parent has changed/);
assert.equal(await page.locator('[data-drag-id="4"]').getAttribute('data-parent-location-id'),'1');
await page.selectOption('[data-location-drag-action]','relationship');
await drag(1,2);
await page.locator('[data-drag-drop-panel]').waitFor({state:'visible'});
assert.equal(await page.locator('[name="FromLocationID"]').inputValue(),'1');
assert.equal(await page.locator('[name="ToLocationID"]').inputValue(),'2');
const relationshipScroll = await page.evaluate(() => window.scrollY);
await page.locator('[data-drag-form="location-relationship"] button').click();
await page.locator('[data-location-relationship-modal]').waitFor({state:'hidden'});
await page.locator('.modal-backdrop').waitFor({state:'hidden'});
assert.ok(Math.abs(await page.evaluate(() => window.scrollY) - relationshipScroll) < 2);
assert.equal(await page.evaluate(() => relationshipSaves),1);
assert.deepEqual(errors,[]);
console.log('PASS: drag assignment/reassignment, confirmation, cancellation, invalid targets, AJAX refresh, scroll preservation, server errors, relationship modal/AJAX, full site.js initialization');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });

View File

@ -1,80 +0,0 @@
{
"name": "Browser",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"chart.js": "4.4.9",
"playwright": "1.58.2"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"dev": true,
"license": "MIT"
},
"node_modules/chart.js": {
"version": "4.4.9",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View File

@ -1,5 +0,0 @@
{
"private": true,
"scripts": { "test": "node timeline-metric-layout.cjs", "test:locations": "node location-reparent.cjs" },
"devDependencies": { "chart.js": "4.4.9", "playwright": "1.58.2" }
}

View File

@ -1,198 +0,0 @@
// npm install && npx playwright install chromium && npm test
// Optional: CHROMIUM_PATH=/path/to/chrome npm test
const { chromium } = require('playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const repo = path.resolve(__dirname, '../..');
const source = fs.readFileSync(path.join(repo, 'PlotLine/wwwroot/js/site.js'), 'utf8');
const start = source.indexOf('(() => {\n const chartRoots =');
const chartScript = source.slice(start, source.indexOf('\n})();', start) + 6);
const css = fs.readFileSync(path.join(repo, 'PlotLine/wwwroot/css/site.css'), 'utf8');
const chartJs = path.join(path.dirname(require.resolve('chart.js')), 'chart.umd.js');
const settle = async page => {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.waitForTimeout(100);
};
async function fixture(page, bookCount, sceneCount, variedHeaders = false, mode = "visible") {
const scenes = [];
let books = '';
for (let book = 0; book < bookCount; book++) {
let chapters = '';
for (let chapter = 0; chapter < 3; chapter++) {
let cards = '';
for (let n = 0; n < sceneCount; n++) {
const id = scenes.length + 1;
scenes.push({ sceneId: id, sceneNumber: id, chapterLabel: `Chapter ${chapter + 1}`,
sceneTitle: `Scene ${id}`, axisLabel: `S${id}`, bookTitle: `Book ${book + 1}`, timelineUrl: `https://plotdirector.test/Timeline?selectedSceneId=${id}` });
cards += `<a class="timeline-scene-card" data-scene-id="${id}">Scene ${id}</a>`;
}
chapters += `<div class="timeline-chapter" style="--chapter-scene-count:${sceneCount}"><div class="timeline-chapter-title">Chapter ${chapter + 1}</div><div class="timeline-scenes">${cards}</div></div>`;
}
books += `<div class="timeline-book" style="--book-scene-count:${sceneCount * 3}"><div class="timeline-book-title" ${variedHeaders ? `style="width:${137 + book * 89}px"` : ''}>Book ${book + 1}</div><div class="timeline-chapters">${chapters}</div></div>`;
}
const payload = { scenes, metrics: ['Tension', 'Action'].map((metricName, i) => ({ metricName, metricTypeId: i + 1,
points: scenes.map(s => ({ sceneId: s.sceneId, value: 3 + s.sceneId % 5 })) })) };
await page.setContent(`<style>*{box-sizing:border-box}body{margin:0}${css}
.timeline-filtered-root{--timeline-label-width:180px;--timeline-grid-gap:10px}
${variedHeaders ? '.timeline-book{display:flex;width:max-content;gap:23px}.timeline-book-title{flex-shrink:0}' : ''}
</style><div class="timeline-filtered-root density-comfortable zoom-chapter" data-timeline-root>
<section class="timeline-shell"><div data-collapse-body="scene-cards"><div class="timeline-series-row"><div class="timeline-project-title">Project</div><div class="timeline-books">${books}</div></div></div>
<div class="metric-timeline-board" style="--scene-count:${scenes.length}"><div data-collapse-body="metrics"><div class="metric-shape-chart" data-metric-shape-chart data-timeline-metric-editor>
<div data-metric-toggles></div><div data-timeline-metric-edit-toolbar><input name="__RequestVerificationToken" value="test"></div><span data-edit-metric-status></span>
<div class="metric-timeline-row"><div class="metric-lane-label">Metric Shape</div><div class="metric-chart-frame metric-timeline-frame"><canvas></canvas></div></div>
<script type="application/json">${JSON.stringify(payload)}</script></div></div></div></section></div>`);
if (mode !== "visible") {
await page.evaluate(mode => {
const body = document.querySelector('[data-collapse-body="scene-cards"]');
if (mode === "disabled") body.remove();
else body.classList.add('timeline-section-collapsed');
}, mode);
}
await page.addScriptTag({ path: chartJs });
await page.addScriptTag({ content: chartScript });
await settle(page);
}
async function checkAlignment(page, label) {
const result = await page.evaluate(() => {
const chart = Chart.getChart(document.querySelector('canvas'));
const left = chart.canvas.getBoundingClientRect().left;
const points = chart.data.datasets[0].data;
const errors = points.filter(p => p.sceneId).map(p => {
const rect = document.querySelector(`[data-scene-id="${p.sceneId}"]`).getBoundingClientRect();
return Math.abs(left + chart.scales.x.getPixelForValue(p.x) - rect.left - rect.width / 2);
});
const drawnErrors = chart.getDatasetMeta(0).data.flatMap((point, i) => {
if (point.skip || point.x < chart.chartArea.left || point.x > chart.chartArea.right) return [];
const rect = document.querySelector(`[data-scene-id="${points[i].sceneId}"]`).getBoundingClientRect();
return [Math.abs(left + point.x - rect.left - rect.width / 2)];
});
return { max: Math.max(...errors), drawnMax: Math.max(0, ...drawnErrors), bitmap: chart.canvas.width,
limit: document.querySelector('.timeline-shell').clientWidth * devicePixelRatio + 1 };
});
assert.ok(result.max < 0.5, `${label}: scale drift ${JSON.stringify(result)}`);
assert.ok(result.drawnMax < 0.5, `${label}: rendered drift ${JSON.stringify(result)}`);
assert.ok(result.bitmap <= result.limit, `${label}: canvas exceeds viewport`);
}
async function checkInteractions(page) {
await page.evaluate(() => {
const canvas = document.querySelector('canvas');
canvas.scrollIntoView({ block: 'center' });
const shell = document.querySelector('.timeline-shell');
shell.scrollLeft = (shell.scrollWidth - shell.clientWidth) / 2;
});
await settle(page);
const point = await page.evaluate(() => {
const chart = Chart.getChart(document.querySelector('canvas'));
const index = chart.getDatasetMeta(0).data.findIndex(p => !p.skip && p.x > chart.width / 2 && p.x < chart.chartArea.right - 20);
const p = chart.getDatasetMeta(0).data[index];
const rect = chart.canvas.getBoundingClientRect();
return { index, sceneId: chart.data.datasets[0].data[index].sceneId, x: rect.left + p.x, y: rect.top + p.y };
});
assert.ok(point.index > 0, 'exercise a scene well beyond the first');
await page.mouse.move(point.x, point.y);
await settle(page);
assert.ok(await page.evaluate(id => {
const chart = Chart.getChart(document.querySelector('canvas'));
return chart.tooltip.title[0].endsWith(`Scene ${id}`);
}, point.sceneId), 'tooltip must resolve scene identity from dataset index');
await page.evaluate(() => {
const checkboxes = document.querySelectorAll('[data-metric-toggles] input');
checkboxes[1].click();
window.savedMetric = null;
window.fetch = async (_, options) => {
window.savedMetric = JSON.parse(options.body);
return { ok: true, json: async () => ({ success: true, value: window.savedMetric.value }) };
};
});
await page.mouse.move(point.x, point.y);
await page.mouse.down();
await page.mouse.move(point.x, point.y - 25, { steps: 3 });
await page.mouse.up();
await settle(page);
const saved = await page.evaluate(() => window.savedMetric);
assert.equal(saved.sceneId, point.sceneId, 'drag saves the correct scene');
assert.equal(saved.metricId, 1);
await checkAlignment(page, 'after metric editing');
await page.evaluate(() => document.querySelectorAll('[data-metric-toggles] input')[1].click());
assert.equal(await page.evaluate(() => Chart.getChart(document.querySelector('canvas')).isDatasetVisible(1)), true);
const navigation = page.waitForRequest(request => request.url().includes('selectedSceneId='));
await page.route('https://plotdirector.test/**', route => route.abort());
await page.evaluate(index => {
const chart = Chart.getChart(document.querySelector('canvas'));
const p = chart.getDatasetMeta(0).data[index];
const rect = chart.canvas.getBoundingClientRect();
chart.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: rect.left + p.x, clientY: rect.top + p.y }));
}, point.index);
assert.ok((await navigation).url().endsWith(`selectedSceneId=${point.sceneId}`), 'point click targets its scene');
}
(async () => {
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH });
try {
for (const dpr of [1, 2]) {
const page = await browser.newPage({ viewport: { width: 1100, height: 900 }, deviceScaleFactor: dpr });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
for (const [books, count, headers] of [[1, 12, false], [3, 18, true]]) {
await fixture(page, books, count, headers);
for (const density of ['compact', 'comfortable', 'expanded']) {
for (const zoom of ['book', 'chapter', 'scene-detail', 'scene-overview']) {
await page.evaluate(({ density, zoom }) => {
document.querySelector('[data-timeline-root]').className = `timeline-filtered-root density-${density} zoom-${zoom}`;
}, { density, zoom });
await settle(page);
for (const fraction of [0, 0.5, 1]) {
await page.evaluate(f => {
const shell = document.querySelector('.timeline-shell');
shell.scrollLeft = (shell.scrollWidth - shell.clientWidth) * f;
}, fraction);
await settle(page);
await checkAlignment(page, `${books} books ${density}/${zoom} scroll=${fraction} dpr=${dpr}`);
}
}
}
await page.setViewportSize({ width: 760, height: 900 });
await settle(page);
await checkAlignment(page, 'resize');
// Width changes without an application event must trigger ResizeObserver.
await page.evaluate(() => {
document.querySelectorAll('.timeline-scene-card').forEach((el, i) => el.style.width = `${120 + i % 4 * 73}px`);
document.querySelectorAll('.timeline-chapter,.timeline-book').forEach(el => el.style.width = 'max-content');
});
await settle(page);
await checkAlignment(page, 'unequal scene widths');
for (const section of ['metrics', 'scene-cards']) {
await page.evaluate(section => document.querySelector(`[data-collapse-body="${section}"]`).classList.add('timeline-section-collapsed'), section);
await settle(page);
await page.evaluate(section => {
document.querySelector(`[data-collapse-body="${section}"]`).classList.remove('timeline-section-collapsed');
window.dispatchEvent(new Event('plotline:timeline-layout-changed'));
}, section);
await settle(page);
await checkAlignment(page, `expand ${section}`);
}
await page.setViewportSize({ width: 1100, height: 900 });
}
await fixture(page, 1, 6, false, 'collapsed');
await page.evaluate(() => {
document.querySelector('[data-collapse-body="scene-cards"]').classList.remove('timeline-section-collapsed');
window.dispatchEvent(new Event('plotline:timeline-layout-changed'));
});
await settle(page);
await checkAlignment(page, 'initially collapsed scene cards');
await fixture(page, 1, 6, false, 'disabled');
assert.equal(await page.evaluate(() => Chart.getChart(document.querySelector('canvas')).scales.x.type), 'linear', 'standalone layout remains supported');
await fixture(page, 3, 18, true);
await checkInteractions(page);
assert.deepEqual(errors, []);
await page.close();
}
console.log('PASS: timeline metric geometry and interactions (views/densities, single/multi-book, unequal widths, scrolling, resize, collapse/disabled cards, DPR 1/2, long timelines, tooltips, toggles, click, drag-save)');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });

View File

@ -1,78 +0,0 @@
using System.Reflection;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
internal static class CharacterProfileAgeTests
{
private static void Check(bool value, string message) { if (!value) throw new Exception(message); }
public static void ProfileUsesStoryDate()
{
var character = new Character { CharacterID = 1, BirthDate = new DateTime(1965, 9, 1) };
var first = new Scene { SceneID = 900, TimeModeName = "Exact Date", StartDateTime = new DateTime(1980, 5, 7) };
var later = new Scene { SceneID = 2, TimeModeName = "Exact Date", StartDateTime = new DateTime(1982, 12, 24) };
SceneCharacter[] appearances = [new() { CharacterID = 1, SceneID = 2 }, new() { CharacterID = 1, SceneID = 900 }];
var result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances);
Check(result.DisplayText == "14 years, 8 months" && result.ReferenceScene?.SceneID == 900, "Age must use first narrative dated appearance, not current date or minimum scene ID.");
character.AgeReferenceSceneID = 2;
result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances);
Check(result.DisplayText == "17 years, 3 months" && result.Context == "At age reference scene", "Explicit age reference must take precedence.");
character.AgeReferenceSceneID = null;
first.StartDateTime = null;
Check(CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances).ReferenceScene?.SceneID == 2, "Undated appearance must not invent a reference date.");
later.StartDateTime = null;
result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances);
Check(result.ReferenceScene is null && result.Context.Contains("dated story scene"), "Known DOB without chronology must explain why age is unavailable.");
character.AgeAtSeriesStart = 17;
Check(CharacterAgeDisplayService.GetProfileAge(character, [], []).DisplayText == "About 17", "Approximate fallback must stay available.");
character.BirthDate = null; character.AgeAtSeriesStart = null;
Check(CharacterAgeDisplayService.GetProfileAge(character, [], []).DisplayText == "Unknown", "Missing date/age must stay unknown.");
}
public static void DetailAndSaveUseCanonicalBirthDate()
{
var character = new Character { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = new DateTime(1965, 9, 1), AgeReferenceSceneID = 900 };
var timeline = new TimelineData { Project = new Project { ProjectID = 1 }, Books = [new Book { BookID = 1 }], Chapters = [new Chapter { ChapterID = 1, BookID = 1 }],
Scenes = [new Scene { SceneID = 900, ChapterID = 1, TimeModeName = "Exact Date", StartDateTime = new DateTime(1980, 5, 7) }] };
Character? saved = null;
var repository = Proxy<ICharacterRepository>((method, args) => method switch {
"GetCharacterAsync" => Task.FromResult<Character?>(character),
"ListCharactersAsync" => Task.FromResult<IReadOnlyList<Character>>([character]),
"GetTimelineAsync" => Task.FromResult(((IReadOnlyList<Character>)[character], (IReadOnlyList<SceneCharacter>)[new SceneCharacter { CharacterID = 1, SceneID = 900 }])),
"GetLookupsAsync" => Task.FromResult(new CharacterLookupData()),
"SaveCharacterAsync" => Capture((Character)args![0]!),
_ => Default<ICharacterRepository>(method) });
Task<int> Capture(Character value) { saved = value; return Task.FromResult(1); }
var service = new CharacterService(
Proxy<IProjectRepository>((_, _) => Task.FromResult<Project?>(timeline.Project)),
Proxy<IBookRepository>(), repository, Proxy<IProjectCollaborationRepository>(), Proxy<ISubscriptionService>(),
Proxy<IProjectActivityService>(), Proxy<ICurrentUserService>(), Proxy<IVisualIdentityImageService>(), Proxy<ICharacterEnrichmentService>(),
Proxy<ITimelineRepository>((_, _) => Task.FromResult(timeline)));
var detail = service.GetCharacterDetailAsync(1).GetAwaiter().GetResult()!;
Check(detail.DisplayAge == "14 years, 8 months" && detail.AgeReferenceScene?.SceneID == 900, "Detail runtime must supply canonical chronology to age service.");
Check(detail.Character.BirthDate == character.BirthDate, "Detail must keep authored DOB independently of generated analysis.");
service.SaveCharacterAsync(new CharacterEditViewModel { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = new DateTime(1965, 9, 1) }).GetAwaiter().GetResult();
Check(saved?.BirthDate == new DateTime(1965, 9, 1) && saved.AgeReferenceSceneID == 900, "Date-only save must preserve date and existing reference scene.");
service.SaveCharacterAsync(new CharacterEditViewModel { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = null }).GetAwaiter().GetResult();
Check(saved?.BirthDate is null, "Birth date must remain optional and clearable.");
}
private static T Proxy<T>(Func<string, object?[]?, object?>? handler = null) where T : class
{
var result = DispatchProxy.Create<T, StoryPointProxy>();
((StoryPointProxy)(object)result).Handler = handler ?? ((method, _) => Default<T>(method));
return result;
}
private static object? Default<T>(string name)
{
var type = typeof(T).GetMethods().First(x => x.Name == name).ReturnType;
if (type == typeof(Task)) return Task.CompletedTask;
if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(Task<>)) return type.IsValueType ? Activator.CreateInstance(type) : null;
var valueType = type.GenericTypeArguments[0];
object? value = valueType.IsGenericType && valueType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)
? Array.CreateInstance(valueType.GenericTypeArguments[0], 0)
: valueType.IsValueType ? Activator.CreateInstance(valueType) : null;
return typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(valueType).Invoke(null, [value]);
}
}

View File

@ -1,31 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using PlotLine.Services;
using PlotLine.ViewModels;
internal static class EntityMergeTests
{
public static void ConfirmationAndDistinctRecordsAreRequired()
{
// Null dependencies make any repository access before validation fail this test.
var locations = new LocationService(null!, null!, null!, null!, NullLogger<LocationService>.Instance);
var assets = new AssetService(null!, null!, null!, null!, null!, null!);
foreach (var merge in new Func<EntityMergeForm, Task>[]
{
async form => { await locations.MergeAsync(form); },
async form => { await assets.MergeAsync(form); }
})
{
Reject(merge, new EntityMergeForm { SourceID = 1, TargetID = 2 }, "Confirm");
Reject(merge, new EntityMergeForm { SourceID = 1, TargetID = 1, Confirm = true }, "different");
Reject(merge, new EntityMergeForm { SourceID = 1, TargetID = 0, Confirm = true }, "different");
Reject(merge, new EntityMergeForm { SourceID = 0, TargetID = 2, Confirm = true }, "different");
}
}
private static void Reject(Func<EntityMergeForm, Task> merge, EntityMergeForm form, string message)
{
try { merge(form).GetAwaiter().GetResult(); }
catch (InvalidOperationException ex) when (ex.Message.Contains(message, StringComparison.Ordinal)) { return; }
throw new Exception("Invalid merge was not rejected before database access.");
}
}

View File

@ -1,108 +0,0 @@
using PlotLine.Models;
using PlotLine.Data;
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using PlotLine.Services;
using PlotLine.ViewModels;
internal static class LocationSignificanceTests
{
private static void Check(bool condition, string message) { if (!condition) throw new Exception(message); }
private static LocationSignificanceEvidence Evidence(int scene, int chapter, bool setting = false, int eventScore = 0, int context = 0)
=> new(scene, chapter, 1, $"Chapter {chapter}, Scene {scene}", setting, eventScore, context, [], [], []);
private static StoryIntelligenceLocationReviewCandidateViewModel Candidate(string key, int score, string? parent = null, string confidence = "High", string status = "Pending")
=> new() { Key = key, LocationName = key, Category = "Named location", ProposedParentCandidateKey = parent, Confidence = confidence, DecisionStatus = status,
Significance = new() { Score = score, Classification = score >= 40 ? "Significant Location" : "Incidental Location" } };
public static void ConsequenceOutranksFrequency()
{
var hallway = LocationSignificanceService.Assess("Room / interior area", Enumerable.Range(1, 100).Select(i => Evidence(i, i, true, context: 18)));
var observatory = LocationSignificanceService.Assess("Named location", [Evidence(200, 1, true, 50, 18)]);
Check(observatory.Score > hallway.Score && observatory.IsSignificant && !hallway.IsSignificant, "One consequential event must outrank 100 routine setting scenes.");
Check(hallway.Score == 26 && hallway.Classification == "Recurring Location", "Frequency must be capped at eight and cannot alone create a primary setting.");
var duplicates = LocationSignificanceService.Assess("Named location", Enumerable.Repeat(Evidence(200, 1, true, 50, 18), 100));
Check(duplicates.Score == observatory.Score, "Repeated observations in the same scene must not inflate score.");
Check(LocationSignificanceService.Assess("Named location", [Evidence(1, 1)]).Classification == "Incidental Location", "A distinctive name alone is incidental.");
Check(LocationSignificanceService.Assess("Named location", Enumerable.Range(1, 6).Select(i => Evidence(i, i, true, 50, 18))).Classification == "Primary Setting", "Consequential multi-chapter settings should be primary.");
}
public static void SceneContextBelongsToSetting()
{
var parsed = new SceneIntelligenceScene { ScenePurpose = new() { ObservedFunction = "A betrayal and rescue transform the investigation." },
PointOfView = new() { CharacterName = "Leena" },
Metrics = new() { ["tension"] = new() { Score = 9 } },
KnowledgeChanges = [new() { KnowledgeItem = "The captain is the informant" }],
NarrativeArcs = [new() { Title = "The missing expedition", Significance = "high" }] };
var scene = new Scene { SceneID = 7, ChapterID = 3, BookID = 1 };
var site = LocationSignificanceService.Observe(scene, parsed, "Chapter 3, Scene 7", true, true, false, null);
var mention = LocationSignificanceService.Observe(scene, parsed, "Chapter 3, Scene 7", false, false, true, "They mention the pantry.");
Check(site.EventScore == 50 && site.ContextScore > 18 && site.Characters.Contains("Leena") && site.PlotLines.Contains("The missing expedition"), "Reuse structured scene purpose, metrics, knowledge, plot and POV evidence.");
Check(mention.EventScore == 0 && mention.ContextScore == 0 && mention.Characters.Count == 0, "An incidental mention cannot inherit the scene's consequential context.");
var hypothetical = LocationSignificanceService.Observe(scene, parsed, "scene", false, false, true, "They might discover a body at the beach.");
Check(hypothetical.EventScore == 0, "Hypothetical consequential vocabulary must not create an actual event site.");
var routine = new SceneIntelligenceScene { ScenePurpose = new() { ObservedFunction = "The conversation reveals the employee staffing schedule." },
NarrativeArcs = [new() { Title = "Work routines", Significance = "minor" }],
KnowledgeChanges = [new() { KnowledgeItem = "The secretary works on Tuesdays." }] };
var routineScore = LocationSignificanceService.Assess("Named location", [LocationSignificanceService.Observe(scene, routine, "scene", true, true, false, null)]);
Check(!routineScore.IsSignificant, "Ordinary exposition and trivial knowledge must not be mistaken for major revelations.");
var explicitSite = LocationSignificanceService.Observe(scene, parsed, "scene", false, false, true, "The abduction occurred at the lighthouse.");
Check(explicitSite.EventScore == 50, "Location-specific evidence can support a consequential site even when off-stage.");
}
public static void PersistedAnalysisKeepsContextAndParentEvidence()
{
Scene[] scenes = [
new() { SceneID = 1, ChapterID = 1, SceneNumber = 1, POVCharacterName = "Maggie", StructuralSetting = "Maggie's bedroom" },
new() { SceneID = 2, ChapterID = 1, SceneNumber = 2, POVCharacterName = "Beth", StructuralSetting = "Beth's bedroom" },
new() { SceneID = 3, ChapterID = 1, SceneNumber = 3 }
];
var ordinary = new SceneIntelligenceScene { SchemaVersion = "1", Setting = new() { LocationName = "bedroom", GenericRoomType = "bedroom" } };
var major = new SceneIntelligenceScene { SchemaVersion = "1", Setting = new() { LocationName = "library", GenericRoomType = "library", ParentLocationHint = "Westmere House" },
ScenePurpose = new() { ObservedFunction = "A rescue follows the abduction." } };
IReadOnlyList<StoryIntelligenceSavedSceneResult> results = scenes.Select(x => new StoryIntelligenceSavedSceneResult { SceneID = x.SceneID,
ParsedJson = JsonSerializer.Serialize(x.SceneID == 3 ? major : ordinary, new JsonSerializerOptions(JsonSerializerDefaults.Web)) }).ToList();
var runs = Proxy<IStoryIntelligenceResultRepository>((method, _) => method switch {
"GetRunAsync" => Task.FromResult<StoryIntelligenceSavedRun?>(new() { Status = "Completed" }),
"ListSceneResultsAsync" => Task.FromResult(results),
_ => throw new Exception($"Unexpected run operation: {method}") });
var locations = Proxy<ILocationRepository>((method, _) => method == "ListByProjectAsync"
? Task.FromResult<IReadOnlyList<LocationItem>>([]) : throw new Exception($"Unexpected location write/read: {method}"));
var sceneRepository = Proxy<ISceneRepository>((method, _) => method == "ListByChapterAsync"
? Task.FromResult<IReadOnlyList<Scene>>(scenes) : throw new Exception($"Unexpected scene write/read: {method}"));
var importer = new StoryIntelligenceLocationImportService(runs, locations, sceneRepository, null!, NullLogger<StoryIntelligenceLocationImportService>.Instance);
var result = importer.BuildSignificanceAsync(new() { ProjectID = 1, BookID = 1, Items = [new() { RunID = 1, ChapterID = 1 }] }).GetAwaiter().GetResult();
Check(result.Keys.Any(x => x.Contains("maggie", StringComparison.OrdinalIgnoreCase)) && result.Keys.Any(x => x.Contains("beth", StringComparison.OrdinalIgnoreCase)), "Assessment preparation must preserve separate contextual bedrooms.");
Check(result.Any(x => x.Key.Contains("westmere", StringComparison.OrdinalIgnoreCase) && x.Value.IsSignificant), "A house must retain the significance of consequential scenes within it.");
Check(result.Values.SelectMany(x => x.KeyScenes).All(x => x.BookID == 1), "Stored assessment evidence must retain canonical book/scene provenance.");
}
private static T Proxy<T>(Func<string, object?[]?, object?> handler) where T : class
{
var proxy = DispatchProxy.Create<T, StoryPointProxy>();
((StoryPointProxy)(object)proxy).Handler = handler;
return proxy;
}
public static void HierarchyAndUncertainty()
{
var parent = Candidate("Estate", 8);
var high = Candidate("Tower", 90, "Estate");
var low = Candidate("Kitchen", 3, "Estate");
var medium = Candidate("Library", 60, "Estate");
var unrelated = Candidate("Harbour", 70);
var unsure = Candidate("Unresolved room", 0, confidence: "Medium");
var reviewed = Candidate("Reviewed ambiguity", 50, confidence: "Low", status: "Reviewed");
var input = new[] { low, unrelated, medium, unsure, parent, high, reviewed };
var all = LocationSignificanceService.OrderHierarchy(input, "all");
Check(all.Take(4).Select(x => x.Key).SequenceEqual(new[] { "Estate", "Tower", "Library", "Kitchen" }), "Descendants must remain grouped; significant siblings first, significant subtrees first.");
Check(high.HierarchyDepth == 1 && parent.HierarchyDepth == 0, "Hierarchy depth must be retained.");
var significant = LocationSignificanceService.OrderHierarchy(input, "significant");
Check(significant.Contains(parent) && significant.Contains(high) && !significant.Contains(low) && significant.Contains(reviewed), "Significant filter must include ancestors and keep already reviewed evidence accessible.");
var uncertain = LocationSignificanceService.OrderHierarchy(input, "uncertain");
Check(uncertain.Count == 1 && uncertain[0] == unsure, "Only genuine unresolved ambiguity belongs in the uncertainty filter, independently of significance.");
Check(LocationSignificanceService.OrderHierarchy([Candidate("A", 50, "B"), Candidate("B", 0, "A")], "significant").Count == 2, "Legacy cyclic parent hints must terminate and retain all candidates.");
var page = new StoryIntelligenceLocationReviewViewModel { FilteredCount = 52, PendingCandidateCount = 2, PageNumber = 3, PageSize = 25, Candidates = [parent, high] };
Check(page.PageCount == 3 && page.ShowingStart == 51 && page.ShowingEnd == 52 && !page.HasNextPage, "Browse pagination must use filter totals, independently of pending decision counts.");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,93 +0,0 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
internal static class RelationshipDevelopmentTests
{
public static void SceneReviewAndAcceptance() => Run().GetAwaiter().GetResult();
private static async Task Run()
{
foreach (var names in new[] { ("Rob", "Maggie"), ("Iris", "Jon") })
{
var accepted = new List<RelationshipDevelopmentAcceptance>();
var lookups = new CharacterLookupData
{
RelationshipTypes = [new() { RelationshipTypeID=1,TypeName="Other" },new() { RelationshipTypeID=2,TypeName="Sibling" }],
RelationshipStates = [new() { RelationshipStateID=1,StateName="Unknown" },new() { RelationshipStateID=2,StateName="Friendly" },new() { RelationshipStateID=3,StateName="Distrustful" },new() { RelationshipStateID=4,StateName="Romantic" }]
};
var characters = Stub<ICharacterRepository>((m,a) => m switch
{
"ListCharactersAsync" => Task.FromResult<IReadOnlyList<Character>>([new() { CharacterID=1, CharacterName=names.Item1 },new() { CharacterID=2,CharacterName=names.Item2 }]),
"ListAliasesAsync" => Task.FromResult<IReadOnlyList<CharacterAlias>>([]),
"ListRelationshipsByProjectAsync" => Task.FromResult<IReadOnlyList<CharacterRelationship>>([]),
"ListRelationshipEventsBySceneAsync" => Task.FromResult<IReadOnlyList<RelationshipEvent>>([]),
"GetLookupsAsync" => Task.FromResult(lookups),
"AcceptRelationshipDevelopmentAsync" => Save((RelationshipDevelopmentAcceptance)a![0]!),
_ => throw new Exception(m)
});
Task<int> Save(RelationshipDevelopmentAcceptance p) { accepted.Add(p); return Task.FromResult(77); }
SceneIntelligenceRelationship Proposal(string kind,string state,bool mutual,string evidence,int? intensity=null) => new()
{
CharacterA=names.Item1,CharacterB=names.Item2,ProposalKind=kind,RelationshipState=state,
IsReciprocal=mutual,IsKnownToOtherCharacter=false,Intensity=intensity,Confidence=.95m,
Development=evidence,Evidence=evidence,RelationshipSignal="affection",FactualRelationshipType=kind=="Factual"?"Sibling":null
};
var results = new List<StoryIntelligenceSavedSceneResult>
{
Result(30,[Proposal("Development","Romantic",false,"A privately develops feelings for B",3),Proposal("Factual","Unknown",true,"They are siblings")]),
Result(10,[Proposal("Development","Friendly",true,"They help each other escape",6),Proposal("Factual","Unknown",true,"They are siblings")]),
Result(20,[Proposal("Development","Distrustful",false,"A discovers B has lied"),new() { CharacterA=names.Item1,CharacterB=names.Item2,RelationshipSignal="trust",Evidence="They merely appear together with no interaction" }])
};
var runs=Stub<IStoryIntelligenceResultRepository>((m,a)=>m switch
{
"GetRunAsync"=>Task.FromResult<StoryIntelligenceSavedRun?>(new() { Status=StoryIntelligenceRunStatuses.Completed }),
"ListSceneResultsAsync"=>Task.FromResult<IReadOnlyList<StoryIntelligenceSavedSceneResult>>(results),
_=>throw new Exception(m)
});
var scenes=Stub<ISceneRepository>((m,a)=> Task.FromResult<IReadOnlyList<Scene>>([
new() { SceneID=30,SceneNumber=3,ChapterNumber=1 },new() { SceneID=10,SceneNumber=1,ChapterNumber=1 },new() { SceneID=20,SceneNumber=2,ChapterNumber=1 }]));
var pipeline=Stub<IStoryIntelligencePipelineStateService>((m,a)=>Task.FromResult<StoryIntelligenceBookPipelineState?>(null));
var service=new StoryIntelligenceRelationshipImportService(runs,characters,scenes,pipeline,NullLogger<StoryIntelligenceRelationshipImportService>.Instance);
var batch=new OnboardingStoryIntelligenceBatch { ProjectID=1,BookID=2,UserID=3,Items=[new() { RunID=1,ChapterID=1 }] };
var review=await service.BuildReviewAsync(batch);
Check(review.Candidates.Count==4,"Meaningful proposals must stay separate by scene, with co-presence excluded.");
Check(review.Candidates.Select(x=>x.FirstSceneID).SequenceEqual(new int?[]{10,10,20,30}),"Review must follow scene order.");
Check(review.Candidates.Last().IsReciprocal==false && review.Candidates.Last().RelationshipState=="Romantic","One-sided feelings must not become mutual.");
Check(review.Candidates.First(x=>x.ProposalKind=="Factual").RelationshipType=="Sibling","Factual ties must stay separate.");
var form=new StoryIntelligenceRelationshipImportForm { Relationships=review.Candidates.Select(x=>new StoryIntelligenceRelationshipImportChoiceForm
{ Key=x.Key,RelationshipTypeID=x.RelationshipTypeID,RelationshipStateID=x.RelationshipStateID,IsReciprocal=x.IsReciprocal,
IsKnownToOtherCharacter=x.IsKnownToOtherCharacter,Intensity=x.Intensity,Development=x.Development }).ToList() };
form.Relationships.Last().ReverseDirection=true;
var result=await service.ImportAsync(batch,form,false);
Check(result.Success && accepted.Count==4,"Acceptance must save all scene proposals.");
Check(accepted.Last().CharacterAID==2 && accepted.Last().CharacterBID==1 && !accepted.Last().IsReciprocal,"Direction correction must reach persistence.");
Check(accepted.Last().Intensity==3 && accepted.Any(x=>x.Intensity is null),"Confidence must never become intensity.");
Check(accepted.Single(x=>x.IsPermanent).RelationshipTypeID==2,"Only factual ties should be permanent.");
Check(form.Relationships.All(x=>x.AcceptedRelationshipID==77),"Review decisions must reference the canonical relationship ID, not the type ID.");
accepted.Clear();
form.Relationships.Last().RelationshipStateID=999;
Check(!(await service.ImportAsync(batch,form,false)).Success && accepted.Count==0,"Invalid later rows must prevent all writes.");
}
Check(Key(10,1,2,false)!=Key(10,2,1,false),"Directional keys must distinguish reverse feelings.");
Check(Key(10,1,2,true)==Key(10,2,1,true),"Mutual duplicate proposals must share identity.");
Check(Key(10,1,2,false)!=Key(20,1,2,false),"Later scenes must retain independent identity.");
}
private static string Key(int scene,int a,int b,bool reciprocal) => (string)typeof(StoryIntelligenceRelationshipImportService).GetMethod("DevelopmentKey",BindingFlags.NonPublic|BindingFlags.Static)!.Invoke(null,[scene,a,b,reciprocal,"Development","trust","Evidence"])!;
private static StoryIntelligenceSavedSceneResult Result(int scene,List<SceneIntelligenceRelationship> proposals) => new() { SceneID=scene,ParsedJson=JsonSerializer.Serialize(new SceneIntelligenceScene { SchemaVersion="1.0",Relationships=proposals }) };
private static void Check(bool value,string message) { if(!value)throw new Exception(message); }
private static T Stub<T>(Func<string,object?[]?,object?> handler) where T:class
{
var proxy=DispatchProxy.Create<T,RelationshipTestProxy>();
((RelationshipTestProxy)(object)proxy).Handler=handler;
return proxy;
}
}
public class RelationshipTestProxy:DispatchProxy
{
public Func<string,object?[]?,object?> Handler { get; set; }=null!;
protected override object? Invoke(MethodInfo? method,object?[]? args)=>Handler(method!.Name,args);
}

View File

@ -1,243 +0,0 @@
-- Run against a migrated verification/dev database with at least one project and scene.
-- All fixture changes are rolled back, including on assertion failure.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @SceneID int;
SELECT TOP (1) @ProjectID = p.ProjectID, @UserID = p.UserID, @SceneID = s.SceneID
FROM dbo.ProjectUserAccess p JOIN dbo.Books b ON b.ProjectID = p.ProjectID
JOIN dbo.Chapters c ON c.BookID = b.BookID JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID
WHERE p.IsActive = 1;
IF @SceneID IS NULL THROW 51001, 'Merge tests require a project with a scene and active user.', 1;
DECLARE @Source int, @Target int, @Child int;
INSERT dbo.Locations(ProjectID, LocationName) VALUES (@ProjectID, N'Merge test old observatory'); SET @Source = SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID, LocationName, Description) VALUES (@ProjectID, N'Merge test observatory', N'Keep target details'); SET @Target = SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID, LocationName, ParentLocationID) VALUES (@ProjectID, N'Merge test dome', @Source); SET @Child = SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID, Alias) VALUES (@Source, N'North tower'), (@Target, N'North tower'), (@Source, N'Old dome');
INSERT dbo.WordCompanionSceneLocations(SceneID, LocationID) VALUES (@SceneID, @Source), (@SceneID, @Target);
INSERT dbo.SceneLocationSuggestions(SceneID, LocationID, CreatedByUserID) VALUES (@SceneID, @Source, @UserID), (@SceneID, @Target, @UserID);
UPDATE dbo.Scenes SET PrimaryLocationID = @Source WHERE SceneID = @SceneID;
DECLARE @RelType int = (SELECT TOP (1) LocationRelationshipTypeID FROM dbo.LocationRelationshipTypes);
INSERT dbo.LocationRelationships(FromLocationID, ToLocationID, LocationRelationshipTypeID)
VALUES (@Source,@Target,@RelType),(@Source,@Child,@RelType),(@Target,@Child,@RelType);
EXEC dbo.Location_Merge @Source, @Target, @UserID;
-- Exercise the post-merge activity write used by the service, not just the merge procedure.
EXEC dbo.ProjectAudit_Record @ProjectID=@ProjectID, @UserID=@UserID,
@ActivityType=N'Merged', @EntityType=N'Location', @EntityID=@Target,
@EntityName=N'Merge regression target', @Description=N'Merge regression activity';
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectActivity WHERE ProjectID=@ProjectID
AND EntityType=N'Location' AND EntityID=@Target AND ActivityType=N'Merged')
THROW 51001, 'Location merge activity was not recorded.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Source AND IsArchived=1) THROW 51001, 'Source location was not archived.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Child AND ParentLocationID=@Target) THROW 51001, 'Child location was not moved.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.Scenes WHERE SceneID=@SceneID AND PrimaryLocationID=@Target) THROW 51001, 'Primary scene location was not moved.', 1;
IF (SELECT COUNT(*) FROM dbo.LocationAliases WHERE LocationID=@Target) <> 3 THROW 51001, 'Location names were lost or duplicated.', 1;
IF (SELECT COUNT(*) FROM dbo.WordCompanionSceneLocations WHERE SceneID=@SceneID AND LocationID=@Target) <> 1 THROW 51001, 'Duplicate location scene link.', 1;
IF (SELECT COUNT(*) FROM dbo.SceneLocationSuggestions WHERE SceneID=@SceneID AND LocationID=@Target AND Status=N'Pending') <> 1 THROW 51001, 'Duplicate location suggestion.', 1;
IF (SELECT COUNT(*) FROM dbo.LocationRelationships WHERE FromLocationID=@Target AND ToLocationID=@Child AND IsArchived=0) <> 1 THROW 51001, 'Duplicate location relationship.', 1;
IF EXISTS (SELECT 1 FROM dbo.LocationRelationships WHERE (FromLocationID=@Source OR ToLocationID=@Source) AND IsArchived=0) THROW 51001, 'Active relationship left on source.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Target AND Description=N'Keep target details') THROW 51001, 'Target location details changed.', 1;
DECLARE @Kind int = (SELECT TOP (1) AssetKindID FROM dbo.AssetKinds), @Other int;
INSERT dbo.StoryAssets(ProjectID, AssetName, AssetKindID) VALUES (@ProjectID,N'Merge test brass compass',@Kind); SET @Source=SCOPE_IDENTITY();
INSERT dbo.StoryAssets(ProjectID, AssetName, AssetKindID,Description) VALUES (@ProjectID,N'Merge test compass',@Kind,N'Keep target details'); SET @Target=SCOPE_IDENTITY();
INSERT dbo.StoryAssets(ProjectID, AssetName, AssetKindID) VALUES (@ProjectID,N'Merge test map',@Kind); SET @Other=SCOPE_IDENTITY();
INSERT dbo.AssetAliases(StoryAssetID, Alias) VALUES (@Source,N'Navigator'),(@Target,N'Navigator'),(@Source,N'Brass guide');
INSERT dbo.WordCompanionSceneAssets(SceneID,StoryAssetID) VALUES (@SceneID,@Source),(@SceneID,@Target);
INSERT dbo.SceneAssetSuggestions(SceneID,AssetID,CreatedByUserID) VALUES (@SceneID,@Source,@UserID),(@SceneID,@Target,@UserID);
DECLARE @EventType int=(SELECT TOP (1) AssetEventTypeID FROM dbo.AssetEventTypes), @CustodyType int=(SELECT TOP (1) AssetCustodyEventTypeID FROM dbo.AssetCustodyEventTypes);
INSERT dbo.AssetEvents(StoryAssetID,SceneID,AssetEventTypeID,EventTitle) VALUES (@Source,@SceneID,@EventType,N'Merge test event');
DECLARE @EventID int=SCOPE_IDENTITY();
INSERT dbo.AssetCustodyEvents(StoryAssetID,SceneID,AssetCustodyEventTypeID) VALUES (@Source,@SceneID,@CustodyType);
DECLARE @CustodyID int=SCOPE_IDENTITY(), @DepType int=(SELECT TOP (1) AssetDependencyTypeID FROM dbo.AssetDependencyTypes);
INSERT dbo.AssetDependencies(SourceAssetID,TargetAssetID,AssetDependencyTypeID) VALUES (@Source,@Target,@DepType),(@Source,@Other,@DepType),(@Target,@Other,@DepType);
EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID;
-- Exercise the post-merge activity write used by the service, not just the merge procedure.
EXEC dbo.ProjectAudit_Record @ProjectID=@ProjectID, @UserID=@UserID,
@ActivityType=N'Merged', @EntityType=N'Asset', @EntityID=@Target,
@EntityName=N'Merge regression target', @Description=N'Merge regression activity';
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectActivity WHERE ProjectID=@ProjectID
AND EntityType=N'Asset' AND EntityID=@Target AND ActivityType=N'Merged')
THROW 51001, 'Asset merge activity was not recorded.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.StoryAssets WHERE StoryAssetID=@Source AND IsArchived=1) THROW 51001, 'Source asset was not archived.', 1;
IF (SELECT COUNT(*) FROM dbo.AssetAliases WHERE StoryAssetID=@Target) <> 3 THROW 51001, 'Asset names were lost or duplicated.', 1;
IF (SELECT COUNT(*) FROM dbo.WordCompanionSceneAssets WHERE SceneID=@SceneID AND StoryAssetID=@Target) <> 1 THROW 51001, 'Duplicate asset scene link.', 1;
IF (SELECT COUNT(*) FROM dbo.SceneAssetSuggestions WHERE SceneID=@SceneID AND AssetID=@Target AND Status=N'Pending') <> 1 THROW 51001, 'Duplicate asset suggestion.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.AssetEvents WHERE AssetEventID=@EventID AND StoryAssetID=@Target) THROW 51001, 'Asset event was not moved.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.AssetCustodyEvents WHERE AssetCustodyEventID=@CustodyID AND StoryAssetID=@Target) THROW 51001, 'Custody was not moved.', 1;
IF (SELECT COUNT(*) FROM dbo.AssetDependencies WHERE SourceAssetID=@Target AND TargetAssetID=@Other AND IsArchived=0) <> 1 THROW 51001, 'Duplicate asset dependency.', 1;
IF EXISTS (SELECT 1 FROM dbo.AssetDependencies WHERE (SourceAssetID=@Source OR TargetAssetID=@Source) AND IsArchived=0) THROW 51001, 'Active dependency left on source.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.StoryAssets WHERE StoryAssetID=@Target AND Description=N'Keep target details') THROW 51001, 'Target asset details changed.', 1;
ROLLBACK TRANSACTION;
SELECT N'PASS: location and asset merges; fixtures rolled back' AS Result;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO
-- Hierarchy failure must also leave the source unmodified (the procedure rolls back the fixtures).
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge cycle test parent'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Merge cycle test child',@Source); SET @Target=SCOPE_IDENTITY();
EXEC dbo.Location_Merge @Source,@Target,@UserID;
THROW 51001, 'Hierarchy cycle was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 OR ERROR_MESSAGE() NOT LIKE '%descendants%' THROW;
SELECT N'PASS: hierarchy cycle rejected; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject same record for Location without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY();
SET @Target=@Source;
EXEC dbo.Location_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: Location rejects same record; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject archived target for Location without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY();
UPDATE dbo.Locations SET IsArchived=1 WHERE LocationID=@Target;
EXEC dbo.Location_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: Location rejects archived target; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject other project for Location without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY();
INSERT dbo.Projects(ProjectName) VALUES (N'Merge test other project');
DECLARE @OtherProjectID int=SCOPE_IDENTITY();
UPDATE dbo.Locations SET ProjectID=@OtherProjectID WHERE LocationID=@Target;
EXEC dbo.Location_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: Location rejects other project; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject unauthorized user for Location without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY();
SET @UserID=NULL;
EXEC dbo.Location_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: Location rejects unauthorized user; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject same record for StoryAsset without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY();
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY();
SET @Target=@Source;
EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: StoryAsset rejects same record; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject archived target for StoryAsset without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY();
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY();
UPDATE dbo.StoryAssets SET IsArchived=1 WHERE StoryAssetID=@Target;
EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: StoryAsset rejects archived target; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject other project for StoryAsset without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY();
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY();
INSERT dbo.Projects(ProjectName) VALUES (N'Merge test other project');
DECLARE @OtherProjectID int=SCOPE_IDENTITY();
UPDATE dbo.StoryAssets SET ProjectID=@OtherProjectID WHERE StoryAssetID=@Target;
EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: StoryAsset rejects other project; fixtures rolled back' AS Result;
END CATCH;
GO
-- Reject unauthorized user for StoryAsset without leaving fixture data behind.
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int, @Source int, @Target int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY();
INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY();
SET @UserID=NULL;
EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID;
THROW 51001, 'Invalid merge was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 51000 THROW;
SELECT N'PASS: StoryAsset rejects unauthorized user; fixtures rolled back' AS Result;
END CATCH;

View File

@ -1,160 +0,0 @@
-- Regression fixtures are always rolled back. Requires a migrated database and active project user.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Source AND ParentLocationID=@Parent) THROW 51001,'Initial parent not assigned.',1;
EXEC dbo.Location_Reparent @ProjectID,@Source,@Other,@Parent,@UserID;
IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Source AND ParentLocationID=@Other AND Description=N'Preserve details') THROW 51001,'Reassignment changed details or failed.',1;
IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Child AND ParentLocationID=@Source) THROW 51001,'Subtree changed.',1;
IF NOT EXISTS (SELECT 1 FROM dbo.LocationAliases WHERE LocationID=@Source AND Alias=N'Room alias') THROW 51001,'Alias changed.',1;
IF (SELECT COUNT(*) FROM dbo.ProjectActivity WHERE EntityType=N'Location' AND EntityID=@Source AND ActivityType=N'Updated')<>2 THROW 51001,'Audit entries missing.',1;
ROLLBACK TRANSACTION;
SELECT N'PASS: assign, reassign, preserve subtree/details/aliases, audit' AS Result;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
EXEC dbo.Location_Reparent @ProjectID,@Source,@Source,NULL,@UserID;
THROW 51001,'Invalid move was accepted.',1;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER()<>51000 THROW;
SELECT N'PASS: reject self; fixtures rolled back' AS Result;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
EXEC dbo.Location_Reparent @ProjectID,@Source,@Child,NULL,@UserID;
THROW 51001,'Invalid move was accepted.',1;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER()<>51000 THROW;
SELECT N'PASS: reject descendant; fixtures rolled back' AS Result;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,@Other,@UserID;
THROW 51001,'Invalid move was accepted.',1;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER()<>51000 THROW;
SELECT N'PASS: reject stale parent; fixtures rolled back' AS Result;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
UPDATE dbo.Locations SET ParentLocationID=@Parent WHERE LocationID=@Source;
EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,@Parent,@UserID;
THROW 51001,'Invalid move was accepted.',1;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER()<>51000 THROW;
SELECT N'PASS: reject same parent; fixtures rolled back' AS Result;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
UPDATE dbo.Locations SET IsArchived=1 WHERE LocationID=@Parent;
EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
THROW 51001,'Invalid move was accepted.',1;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER()<>51000 THROW;
SELECT N'PASS: reject archived parent; fixtures rolled back' AS Result;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
INSERT dbo.Projects(ProjectName) VALUES (N'Reparent other project'); DECLARE @OtherProject int=SCOPE_IDENTITY(); UPDATE dbo.Locations SET ProjectID=@OtherProject WHERE LocationID=@Parent;
EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
THROW 51001,'Invalid move was accepted.',1;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER()<>51000 THROW;
SELECT N'PASS: reject cross-project; fixtures rolled back' AS Result;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
SET @UserID=NULL;
EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
THROW 51001,'Invalid move was accepted.',1;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER()<>51000 THROW;
SELECT N'PASS: reject unauthorized; fixtures rolled back' AS Result;
END CATCH;
GO

View File

@ -1,38 +0,0 @@
-- Exercise the same audit procedure used by the merge services; leave no data behind.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @UserID int;
SELECT TOP (1) @ProjectID=ProjectID, @UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
IF @ProjectID IS NULL THROW 51001, 'Audit tests require a project with an active user.', 1;
EXEC dbo.ProjectAudit_Record @ProjectID=@ProjectID, @UserID=@UserID,
@ActivityType=N'Merged', @EntityType=N'Character', @EntityName=N'Merge audit regression';
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectActivity WHERE ProjectID=@ProjectID
AND ActivityType=N'Merged' AND EntityType=N'Character' AND EntityName=N'Merge audit regression')
THROW 51001, 'Character merge activity was not recorded.', 1;
-- Preserve all activity types that were allowed before the merge migration.
INSERT dbo.ProjectActivity(ProjectID,UserID,ActivityType,EntityType,EntityName)
SELECT @ProjectID,@UserID,ActivityType,N'Project',N'Merge audit regression'
FROM (VALUES (N'Created'),(N'Updated'),(N'Deleted'),(N'Archived'),(N'Restored'),
(N'Shared'),(N'CollaboratorRemoved'),(N'Exported')) types(ActivityType);
ROLLBACK TRANSACTION;
SELECT N'PASS: character merge and all existing activity types; fixtures rolled back' AS Result;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int=(SELECT TOP (1) ProjectID FROM dbo.Projects);
EXEC dbo.ProjectAudit_Record @ProjectID=@ProjectID, @ActivityType=N'NotAnActivityType',
@EntityType=N'Project', @EntityName=N'Merge audit regression';
THROW 51001, 'Unknown activity type was accepted.', 1;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
IF ERROR_NUMBER() <> 547 OR ERROR_MESSAGE() NOT LIKE N'%CK_ProjectActivity_ActivityType%' THROW;
SELECT N'PASS: unknown activity types remain rejected; fixtures rolled back' AS Result;
END CATCH;
GO

View File

@ -1,44 +0,0 @@
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT dbo.Projects(ProjectName) VALUES(N'Relationship development regression');
DECLARE @Project int=SCOPE_IDENTITY();
INSERT dbo.Books(ProjectID,BookTitle,BookNumber,SortOrder) VALUES(@Project,N'Regression',1,1);
DECLARE @Book int=SCOPE_IDENTITY(),@Revision int=(SELECT TOP(1) RevisionStatusID FROM dbo.RevisionStatuses),
@TimeMode int=(SELECT TOP(1) TimeModeID FROM dbo.TimeModes),@TimeConfidence int=(SELECT TOP(1) TimeConfidenceID FROM dbo.TimeConfidences);
INSERT dbo.Chapters(BookID,ChapterNumber,ChapterTitle,SortOrder,RevisionStatusID) VALUES(@Book,1,N'Chapter',1,@Revision);
DECLARE @Chapter int=SCOPE_IDENTITY();
INSERT dbo.Scenes(ChapterID,SceneNumber,SceneTitle,SortOrder,TimeModeID,TimeConfidenceID,RevisionStatusID)
VALUES(@Chapter,1,N'Early',1,@TimeMode,@TimeConfidence,@Revision);
DECLARE @Early int=SCOPE_IDENTITY();
INSERT dbo.Scenes(ChapterID,SceneNumber,SceneTitle,SortOrder,TimeModeID,TimeConfidenceID,RevisionStatusID)
VALUES(@Chapter,2,N'Later',2,@TimeMode,@TimeConfidence,@Revision);
DECLARE @Later int=SCOPE_IDENTITY();
INSERT dbo.Characters(ProjectID,CharacterName) VALUES(@Project,N'Iris');
DECLARE @A int=SCOPE_IDENTITY();
INSERT dbo.Characters(ProjectID,CharacterName) VALUES(@Project,N'Jon');
DECLARE @B int=SCOPE_IDENTITY();
DECLARE @Other int=(SELECT RelationshipTypeID FROM dbo.RelationshipTypes WHERE TypeName=N'Other'),
@Sibling int=(SELECT RelationshipTypeID FROM dbo.RelationshipTypes WHERE TypeName=N'Sibling'),
@Friendly int=(SELECT RelationshipStateID FROM dbo.RelationshipStates WHERE StateName=N'Friendly'),
@Distrust int=(SELECT RelationshipStateID FROM dbo.RelationshipStates WHERE StateName=N'Distrustful');
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Later,N'regression:later',@A,@B,@Other,@Distrust,0,0,0,NULL,N'Iris discovers the deception.';
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:early',@A,@B,@Other,@Friendly,0,0,0,3,N'Iris confides in Jon.';
-- Repeated acceptance must not duplicate or overwrite the original event.
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:early',@A,@B,@Other,@Distrust,0,0,1,9,N'Changed retry';
IF (SELECT COUNT(*) FROM dbo.CharacterRelationships WHERE ProjectID=@Project)<>1 THROW 51001,'Scene states were split into competing tracks.',1;
IF (SELECT COUNT(*) FROM dbo.RelationshipEvents WHERE SceneID IN(@Early,@Later))<>2 THROW 51001,'Retry duplicated an event.',1;
IF NOT EXISTS(SELECT 1 FROM dbo.CharacterRelationships WHERE ProjectID=@Project AND StartSceneID=@Early AND InitialRelationshipStateID IS NULL) THROW 51001,'Earlier acceptance must move start without projecting a later state backwards.',1;
IF NOT EXISTS(SELECT 1 FROM dbo.RelationshipEvents WHERE SceneID=@Early AND Intensity=3 AND IsKnownToOtherCharacter=0 AND RelationshipStateID=@Friendly) THROW 51001,'Retry overwrote accepted evidence.',1;
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:reverse',@B,@A,@Other,@Distrust,0,0,0,NULL,N'Jon distrusts Iris.';
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:factual',@A,@B,@Sibling,@Friendly,1,1,1,NULL,N'They reveal they are siblings.';
IF (SELECT COUNT(*) FROM dbo.CharacterRelationships WHERE ProjectID=@Project)<>3 THROW 51001,'Reverse and factual relationships must remain separate.',1;
IF NOT EXISTS(SELECT 1 FROM dbo.RelationshipEvents e JOIN dbo.CharacterRelationships r ON r.CharacterRelationshipID=e.CharacterRelationshipID
WHERE r.ProjectID=@Project AND r.CharacterAID=@B AND r.CharacterBID=@A AND r.IsReciprocal=0 AND e.RelationshipStateID=@Distrust) THROW 51001,'Reverse state lost.',1;
ROLLBACK TRANSACTION;
SELECT N'PASS: relationship acceptance, retry, direction, factual ties, chronology and null intensity; rolled back' Result;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;

View File

@ -1,41 +0,0 @@
-- Deliberately scrambled ranks, chapter 2 vs 10, and scene 2 vs 10; all data rolls back.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT dbo.Projects(ProjectName) VALUES (N'Relationship ordering fixture');
DECLARE @ProjectID int=SCOPE_IDENTITY();
INSERT dbo.Books(ProjectID,BookTitle,BookNumber,SortOrder) VALUES (@ProjectID,N'Ordering fixture',1,1);
DECLARE @BookID int=SCOPE_IDENTITY();
INSERT dbo.StoryIntelligenceReviewCandidateGenerations(ProjectID,BookID,Module,Status,CandidateCount,PendingCount)
VALUES (@ProjectID,@BookID,N'Relationships',N'Ready',4,3);
DECLARE @GenerationID int=SCOPE_IDENTITY();
INSERT dbo.StoryIntelligenceReviewCandidates(ReviewCandidateGenerationID,ProjectID,BookID,Module,StableKey,DisplayName,SortOrder,CandidatePayloadJson,DecisionStatus)
SELECT @GenerationID,@ProjectID,@BookID,N'Relationships',k,k,rank,payload,status
FROM (VALUES
(N'late',1,N'{"key":"late","firstChapterNumber":10,"firstSceneNumber":1,"firstSceneID":30}',N'Pending'),
(N'middle',2,N'{"key":"middle","firstChapterNumber":2,"firstSceneNumber":10,"firstSceneID":20}',N'Pending'),
(N'early',3,N'{"key":"early","firstChapterNumber":2,"firstSceneNumber":2,"firstSceneID":10}',N'Pending'),
(N'decided',4,N'{"key":"decided","relationshipType":"Friend"}',N'Reviewed')) data(k,rank,payload,status);
CREATE TABLE #Page (
ReviewCandidateID int,ReviewCandidateGenerationID int,ProjectID int,BookID int,Module nvarchar(40),StableKey nvarchar(450),
CandidateType nvarchar(80),DisplayName nvarchar(450),SortOrder int,Confidence decimal(9,4),ExistingCanonicalID int,
DecisionStatus nvarchar(30),DecisionAction nvarchar(40),DecisionTargetKey nvarchar(450),DecisionCanonicalID int,
CandidatePayloadJson nvarchar(max),CreatedUtc datetime2,ReviewedUtc datetime2,UpdatedUtc datetime2,
TotalCount int,PendingCount int,RowNumber bigint);
INSERT #Page EXEC dbo.StoryIntelligenceReviewCandidate_ListPage @BookID,N'Relationships',1,2;
IF NOT EXISTS(SELECT 1 FROM #Page WHERE RowNumber=1 AND StableKey=N'early') THROW 51001,'First page must begin at chapter 2 scene 2.',1;
IF NOT EXISTS(SELECT 1 FROM #Page WHERE RowNumber=2 AND StableKey=N'middle') THROW 51001,'Scene 10 must follow scene 2.',1;
TRUNCATE TABLE #Page;
INSERT #Page EXEC dbo.StoryIntelligenceReviewCandidate_ListPage @BookID,N'Relationships',2,2;
IF NOT EXISTS(SELECT 1 FROM #Page WHERE RowNumber=3 AND StableKey=N'late') THROW 51001,'Chapter 10 must follow chapter 2 across pages.',1;
EXEC dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships @BookID,@GenerationID,
N'[{"key":"early","relationshipType":"Other","reviewRulesVersion":2,"firstChapterNumber":2,"firstSceneNumber":2},{"key":"decided","relationshipType":"Other","reviewRulesVersion":2}]';
IF NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND StableKey=N'early' AND CandidateType=N'Other') THROW 51001,'Pending suggestion did not refresh.',1;
IF NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND StableKey=N'decided' AND JSON_VALUE(CandidatePayloadJson,'$.relationshipType')=N'Friend') THROW 51001,'Author decision changed.',1;
ROLLBACK TRANSACTION;
SELECT N'PASS: numeric chapter/scene paging and preservation of author decisions; rolled back' AS Result;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;

View File

@ -1,169 +0,0 @@
using System.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
internal static class StoryPointTests
{
private static void Check(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); }
private static StoryPointSnapshot Snapshot(StoryPointInputs? input = null, int scene = 20)
=> StoryPointContextService.Compose(input ?? Fixture(), scene) ?? throw new Exception("Missing snapshot");
public static void PeopleAndChronology()
{
var point = Snapshot();
Check(point.PovName == "Ari", "POV must resolve the timeline ID from the canonical character batch even when Timeline omits its display name.");
Check(point.Characters.Select(x => x.Appearance.CharacterID).SequenceEqual([1, 2]), "Only selected SceneCharacters belong in presence.");
Check(point.Characters[0].PreviousAppearance?.SceneID == 900, "Previous appearance must cross books and exclude selected scene, regardless of IDs.");
Check(point.Characters[1].PreviousAppearance is null, "First appearance has no previous source.");
Check(point.Characters[0].Age.DisplayText == CharacterAgeDisplayService.GetAgeForScene(Fixture().Characters[0], "Exact Date", new DateTime(2001, 2, 5)).DisplayText,
"Age must use existing service and selected date.");
Check(point.Characters[0].Age.DisplayText == "20 years, 1 month", "Exact age is incorrect.");
var input = Fixture(); input.Timeline.Scenes.Single(x => x.SceneID == 20).StartDateTime = null;
Check(Snapshot(input).Characters[0].Age.DisplayText == "About 19", "Undated chronology must use authoritative approximate age.");
Check(Snapshot(input).Characters[1].Age.DisplayText is null, "Unknown age must stay unknown.");
Check(point.PreviousLocationUse?.SceneID == 900, "Previous location use must cross book boundaries.");
}
public static void KnowledgeBoundary()
{
var point = Snapshot();
Check(point.Characters[0].Knowledge.Single().Description == "The gate code", "Prior knowledge must be retained.");
Check(point.Characters[0].Knowledge.Single().KnowledgeStateName == "Knows", "Later knowledge regression must not change earlier state.");
Check(point.Characters[1].Knowledge.Count == 0, "Future knowledge must not leak.");
var later = Snapshot(scene: 1);
Check(later.Characters.Single(x => x.Appearance.CharacterID == 1).Knowledge.Any(x => x.KnowledgeStateName == "Unaware"), "Later knowledge transition must be reflected.");
Check(point.Sources.Keys.ToHashSet().SetEquals([900, 20]), "Snapshot sources must exclude future scenes.");
}
public static void RelationshipsAndAssets()
{
var point = Snapshot();
Check(point.Relationships.Count == 1 && point.Relationships[0].CurrentStateName == "Trusted", "Earlier relationship event must win; future/undated relationships excluded.");
Check(point.Relationships[0].History.All(x => x.SceneID != 1), "Future relationship history must not leak.");
var asset = point.Assets.Single();
Check(asset.Holder == "Ari" && asset.Location == "Harbour" && asset.Status == "Sealed", "Custody, location and status must be independently historical.");
Check(asset.LastConfirmedSceneID == 20, "Latest asset event must stop at selected scene.");
Check(point.Location!.AssetsPresent.Contains("Case"), "Custody must not erase known asset location.");
var initialInput = Fixture();
var undated = initialInput.Relationships.Relationships.Single(x => x.CharacterRelationshipID == 3);
undated.IsInitialRelationship = true; undated.StartSceneID = 1;
Check(Snapshot(initialInput).Relationships.Count == 1, "Future start must override initial flag.");
undated.StartSceneID = null; undated.InitialBookID = 999;
Check(Snapshot(initialInput).Relationships.Count == 1, "Unplaced initial-book boundary must fail closed.");
undated.InitialBookID = null; undated.EndSceneID = 900;
Check(Snapshot(initialInput).Relationships.Count == 1, "Already-ended relationship must not be active.");
var later = Snapshot(scene: 1);
Check(later.Assets.Single(x => x.StoryAssetID == 10).Holder == "Outsider", "Later custody event must take effect only later.");
Check(later.Relationships[0].CurrentStateName == "Hostile", "Later relationship state must take effect.");
}
public static void ThreadsAndWarnings()
{
var point = Snapshot();
Check(point.Threads.Select(x => x.PlotThreadID).ToHashSet().SetEquals([1, 4]), "Active threads include earlier introduction and bounded event introduction; exclude future and already resolved threads.");
Check(point.Threads.Single(x => x.PlotThreadID == 1).State == "Introduced", "Global resolved status must not leak backwards.");
Check(point.Warnings.Any(x => x.CharacterID == 2 && x.WarningType.Contains("Unknown")), "Future location evidence must not suppress current unknown-location warning.");
Check(point.Warnings.All(x => x.SceneID != 1 && x.PreviousSceneId != 1), "Warnings must not contain future evidence.");
Check(Snapshot(scene: 1).Threads.All(x => x.PlotThreadID != 1), "Resolved-at-selected-scene thread is no longer active.");
var input = Fixture();
input.History.ThreadEvents.Single(x => x.PlotThreadID == 4).EventTypeName = "Resolved";
Check(Snapshot(input).Threads.All(x => x.PlotThreadID != 4), "Resolved event must close thread even when resolution field is absent.");
}
public static void OwnershipAndNoAi()
{
var input = Fixture(); int dataCalls = 0;
var access = Proxy<IProjectAccessService>((_, _) => Task.FromResult(false));
var timeline = Proxy<ITimelineRepository>((_, _) => { dataCalls++; return Task.FromResult(input.Timeline); });
var unusedCharacters = Proxy<ICharacterRepository>((_, _) => throw new Exception("Unexpected character query"));
var unusedAssets = Proxy<IAssetRepository>((_, _) => throw new Exception("Unexpected asset query"));
var unusedLocations = Proxy<ILocationRepository>((_, _) => throw new Exception("Unexpected location query"));
var unusedHistory = Proxy<IStoryPointRepository>((_, _) => throw new Exception("Unexpected history query"));
var unusedAcknowledgements = Proxy<IContinuityWarningAcknowledgementRepository>((_, _) => throw new Exception("Unexpected acknowledgement query"));
var service = new StoryPointContextService(access, timeline, unusedCharacters, unusedAssets, unusedLocations, unusedHistory, unusedAcknowledgements);
Check(service.GetAsync(1, 20).Result is null && dataCalls == 0, "Denied project must not load any data.");
access = Proxy<IProjectAccessService>((_, _) => Task.FromResult(true));
service = new StoryPointContextService(access, timeline, unusedCharacters, unusedAssets, unusedLocations, unusedHistory, unusedAcknowledgements);
Check(service.GetAsync(1, 9999).Result is null, "A scene outside the requested project must be rejected before loading history.");
var calls = new List<string>();
var characters = Proxy<ICharacterRepository>((method, _) => { calls.Add(method); return method switch {
nameof(ICharacterRepository.GetTimelineAsync) => Task.FromResult((input.Characters, input.Appearances)),
nameof(ICharacterRepository.GetRelationshipMapAsync) => Task.FromResult(input.Relationships),
_ => throw new Exception("Unexpected per-character query") }; });
var assets = Proxy<IAssetRepository>((method, _) => { calls.Add(method); return Task.FromResult((input.Assets, input.AssetEvents)); });
var locations = Proxy<ILocationRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.Locations); });
var history = Proxy<IStoryPointRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.History); });
var acknowledgements = Proxy<IContinuityWarningAcknowledgementRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.Acknowledgements); });
service = new StoryPointContextService(access, timeline, characters, assets, locations, history, acknowledgements);
Check(service.GetAsync(1, 20).Result?.Snapshot?.Scene.SceneID == 20 && calls.Count == 6, "Page load must compose six history batches with no per-entity calls or AI dependency.");
Check(StoryPointContextService.Compose(input, 9999) is null, "Invalid scene projection must fail closed.");
var controller = new StoryPointController(service) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } };
controller.Request.Headers["X-Requested-With"] = "XMLHttpRequest";
Check(controller.Index(1, 20).Result is PartialViewResult { ViewName: "_Snapshot" }, "AJAX route must render reusable snapshot.");
Check(controller.Index(1, 9999).Result is NotFoundResult, "Controller must reject cross-project/unknown scene.");
Check(controller.Index(0, 20).Result is BadRequestObjectResult, "Missing project must fail.");
}
public static void CanonicalTieBreaks()
{
var input = Fixture();
input.Timeline.Books[0].SortOrder = 1; input.Timeline.Books[1].SortOrder = 1;
Check(StoryStateService.OrderScenes(input.Timeline).Select(x => x.SceneID).SequenceEqual([900, 20, 1]), "BookNumber tie breaker must match NarrativeSceneOrder, not input or ID order.");
Check(Snapshot(input).Relationships.Single().CurrentStateName == "Trusted", "Relationship projection must use the same canonical boundary.");
}
private static T Proxy<T>(Func<string, object?[]?, object?> invoke) where T : class
{ var proxy = DispatchProxy.Create<T, StoryPointProxy>(); ((StoryPointProxy)(object)proxy).Handler = invoke; return proxy; }
private static StoryPointInputs Fixture() => new()
{
Timeline = new TimelineData { Project = new Project { ProjectID = 1 },
Books = [new Book { BookID = 2, BookNumber = 2, SortOrder = 2, BookTitle = "Voyage" }, new Book { BookID = 9, BookNumber = 1, SortOrder = 1, BookTitle = "Departure" }],
Chapters = [new Chapter { ChapterID = 2, BookID = 2, ChapterNumber = 1, SortOrder = 1 }, new Chapter { ChapterID = 9, BookID = 9, ChapterNumber = 1, SortOrder = 1 }],
Scenes = [new Scene { SceneID = 1, ChapterID = 2, SortOrder = 2, SceneNumber = 2, PrimaryLocationID = 5 },
new Scene { SceneID = 20, ChapterID = 2, POVCharacterID = 1, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5, TimeModeName = "Exact Date", StartDateTime = new DateTime(2001, 2, 5) },
new Scene { SceneID = 900, ChapterID = 9, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5 }] },
Characters = [new Character { CharacterID = 1, CharacterName = "Ari", BirthDate = new DateTime(1981, 1, 5), AgeAtSeriesStart = 19 }, new Character { CharacterID = 2, CharacterName = "Bo" }],
Appearances = [new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 900, LocationID = 5, LocationPath = "Harbour" },
new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 20, LocationID = 5, LocationPath = "Harbour" },
new SceneCharacter { CharacterID = 2, CharacterName = "Bo", SceneID = 20 },
new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 1, LocationID = 6, LocationPath = "Tower" },
new SceneCharacter { CharacterID = 2, CharacterName = "Bo", SceneID = 1, LocationID = 6, LocationPath = "Tower" }],
Assets = [new StoryAsset { StoryAssetID = 10, AssetName = "Case" }, new StoryAsset { StoryAssetID = 11, AssetName = "Future object" }],
AssetEvents = [new AssetEvent { AssetEventID = 1, StoryAssetID = 10, SceneID = 900, ToStateName = "Sealed" }, new AssetEvent { AssetEventID = 2, StoryAssetID = 10, SceneID = 1, ToStateName = "Opened" }],
Locations = [new LocationItem { LocationID = 5, LocationName = "Harbour", LocationPath = "Harbour" }],
Relationships = new RelationshipMapData { Relationships = [
new CharacterRelationship { CharacterRelationshipID = 1, CharacterAID = 1, CharacterBID = 2, CharacterAName = "Ari", CharacterBName = "Bo" },
new CharacterRelationship { CharacterRelationshipID = 2, CharacterAID = 1, CharacterBID = 3 },
new CharacterRelationship { CharacterRelationshipID = 3, CharacterAID = 1, CharacterBID = 4 }],
RelationshipEvents = [new RelationshipEvent { CharacterRelationshipID = 1, SceneID = 900, RelationshipStateName = "Trusted" },
new RelationshipEvent { CharacterRelationshipID = 1, SceneID = 1, RelationshipStateName = "Hostile" },
new RelationshipEvent { CharacterRelationshipID = 2, SceneID = 1, RelationshipStateName = "Married" }] },
History = new StoryPointHistory {
Knowledge = [new CharacterKnowledgeItem { CharacterKnowledgeID = 1, CharacterID = 1, SceneID = 900, Description = "The gate code", KnowledgeStateName = "Knows" },
new CharacterKnowledgeItem { CharacterKnowledgeID = 2, CharacterID = 1, SceneID = 1, Description = "The gate code", KnowledgeStateName = "Unaware" },
new CharacterKnowledgeItem { CharacterKnowledgeID = 3, CharacterID = 2, SceneID = 1, Description = "Future secret", KnowledgeStateName = "Knows" }],
AssetLocations = [new SceneAssetLocation { StoryAssetID = 10, SceneID = 900, LocationID = 5, LocationPath = "Harbour" },
new SceneAssetLocation { StoryAssetID = 10, SceneID = 1, LocationID = 6, LocationPath = "Tower" }],
Custody = [new AssetCustodyEvent { AssetCustodyEventID = 1, StoryAssetID = 10, SceneID = 20, CustodianSummary = "Ari" },
new AssetCustodyEvent { AssetCustodyEventID = 2, StoryAssetID = 10, SceneID = 1, CustodianSummary = "Outsider" }],
Custodians = [new AssetCustodyCharacter { AssetCustodyEventID = 1, StoryAssetID = 10, SceneID = 20, CharacterID = 1 },
new AssetCustodyCharacter { AssetCustodyEventID = 2, StoryAssetID = 10, SceneID = 1, CharacterID = 3 }],
Threads = [new PlotThread { PlotThreadID = 1, ThreadTitle = "Open question", ThreadTypeName = "Question", IntroducedSceneID = 900, ActualResolutionSceneID = 1, ThreadStatusName = "Resolved" },
new PlotThread { PlotThreadID = 2, ThreadTitle = "Future", IntroducedSceneID = 1 },
new PlotThread { PlotThreadID = 3, ThreadTitle = "Closed", IntroducedSceneID = 900, ActualResolutionSceneID = 900 },
new PlotThread { PlotThreadID = 4, ThreadTitle = "Clue", ThreadTypeName = "Clue" }],
ThreadEvents = [new ThreadEvent { PlotThreadID = 4, SceneID = 20, EventTypeName = "Introduced" }] }
};
}
public class StoryPointProxy : DispatchProxy
{
public Func<string, object?[]?, object?> Handler { get; set; } = null!;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => Handler(targetMethod!.Name, args);
}

View File

@ -151,47 +151,6 @@ public sealed class CharactersController(ICharacterService characters) : Control
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Reanalyse(int id)
{
try
{
var run = await characters.QueueCharacterReanalysisAsync(id);
if (run is null)
{
return NotFound();
}
TempData["CharacterMessage"] = run.Status == "Running"
? "Character analysis is already running."
: "Character reanalysis queued.";
if (Request.Headers.XRequestedWith == "XMLHttpRequest")
{
return Json(new
{
success = true,
characterEnrichmentRunID = run.CharacterEnrichmentRunID,
bookID = run.BookID,
characterID = run.CharacterID,
status = run.Status,
currentMessage = run.CurrentMessage
});
}
}
catch (InvalidOperationException ex)
{
TempData["CharacterMessage"] = ex.Message;
if (Request.Headers.XRequestedWith == "XMLHttpRequest")
{
return BadRequest(new { success = false, message = ex.Message });
}
}
return RedirectToAction(nameof(Details), new { id });
}
private void ValidateSexWorkflow(CharacterEditViewModel model)
{
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)

View File

@ -32,64 +32,6 @@ public sealed class LocationsController(ILocationService locations) : Controller
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Merge(int id)
{
var model = await locations.GetMergeAsync(id);
return model is null ? NotFound() : View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Merge([Bind(Prefix = "Form")] EntityMergeForm form)
{
if (ModelState.IsValid)
{
try
{
var result = await locations.MergeAsync(form);
if (result is null) return NotFound();
TempData["LocationMessage"] = $"{result.SourceName} merged into {result.TargetName}.";
return RedirectToAction(nameof(Details), new { id = result.TargetID });
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
}
}
var model = await locations.GetMergeAsync(form.SourceID);
if (model is null) return NotFound();
model.Form = form;
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Reparent(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId)
{
if (!ModelState.IsValid)
{
if (IsAjaxRequest()) return BadRequest(new { error = "Invalid location selection. Please try again." });
TempData["LocationError"] = "Invalid location selection. Please try again.";
return RedirectToAction(nameof(Index), new { projectId });
}
try
{
await locations.ReparentAsync(projectId, locationId, parentLocationId, expectedParentLocationId);
if (IsAjaxRequest())
{
var model = await locations.GetLocationsAsync(projectId);
return model is null ? NotFound() : PartialView("_LocationTree", model);
}
TempData["LocationMessage"] = "Location moved to its new parent.";
}
catch (InvalidOperationException ex)
{
if (IsAjaxRequest()) return BadRequest(new { error = ex.Message });
TempData["LocationError"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { projectId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(LocationEditViewModel model)
@ -157,26 +99,14 @@ public sealed class LocationsController(ILocationService locations) : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> DropRelationship(LocationRelationshipEditViewModel model)
{
if (!ModelState.IsValid || model.FromLocationID == model.ToLocationID)
{
if (IsAjaxRequest()) return BadRequest(new { error = "Choose two different locations and a relationship type." });
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
}
try
if (model.FromLocationID != model.ToLocationID)
{
await locations.SaveRelationshipAsync(model);
if (IsAjaxRequest()) return Json(new { message = "Location relationship saved." });
}
catch (InvalidOperationException ex)
{
if (IsAjaxRequest()) return BadRequest(new { error = ex.Message });
TempData["LocationError"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
}
private bool IsAjaxRequest() => Request.Headers["X-Requested-With"] == "XMLHttpRequest";
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ArchiveRelationship(int id, int returnLocationId)

View File

@ -13,7 +13,6 @@ public sealed class OnboardingController(
IOnboardingService onboarding,
IOnboardingStoryIntelligenceService storyIntelligence,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICharacterEnrichmentService characterEnrichment,
ICurrentUserService currentUser,
ILogger<OnboardingController> logger) : Controller
{
@ -410,9 +409,9 @@ public sealed class OnboardingController(
}
[HttpGet("story-intelligence/locations")]
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId, int page = 1, string scope = "significant")
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId, int page = 1)
{
var model = await storyIntelligence.GetLocationReviewAsync(batchId, page, scope: scope);
var model = await storyIntelligence.GetLocationReviewAsync(batchId, page);
if (model is null)
{
return NotFound();
@ -558,10 +557,10 @@ public sealed class OnboardingController(
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (!result.Success)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber), scope = form.Scope });
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber) });
}
var pendingCount = progress.LocationReview.PendingCandidateCount;
var pendingCount = progress.LocationReview.SummaryNeedsAttentionCount ?? progress.LocationReview.PendingCandidateCount;
if (pendingCount <= 0)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID });
@ -575,7 +574,7 @@ public sealed class OnboardingController(
: currentPage;
targetPage = Math.Clamp(targetPage, 1, pageCount);
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage, scope = form.Scope });
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage });
}
[HttpPost("story-intelligence/assets")]
@ -615,11 +614,6 @@ public sealed class OnboardingController(
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form)
{
if (!ModelState.IsValid)
{
TempData["OnboardingStoryIntelligenceError"] = "Check relationship values before saving. Intensity must be a whole number from 1 to 10, or blank.";
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
}
var (progress, result) = await storyIntelligence.ImportRelationshipsAsync(form.BatchID, form);
if (progress is null)
{
@ -754,25 +748,6 @@ public sealed class OnboardingController(
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("story-intelligence/character-enrichment/retry")]
public async Task<IActionResult> RetryStoryIntelligenceCharacterEnrichment(Guid batchId)
{
var model = await storyIntelligence.GetReviewCentreAsync(batchId);
if (model is null)
{
return NotFound();
}
if (!currentUser.UserId.HasValue)
{
return Forbid();
}
await characterEnrichment.RetryAsync(model.BookID, currentUser.UserId.Value);
TempData["OnboardingStoryIntelligenceMessage"] = "Character enrichment has been queued again.";
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID });

View File

@ -32,36 +32,6 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller
return model is null ? NotFound() : View(model);
}
public async Task<IActionResult> Merge(int id)
{
var model = await assets.GetMergeAsync(id);
return model is null ? NotFound() : View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Merge([Bind(Prefix = "Form")] EntityMergeForm form)
{
if (ModelState.IsValid)
{
try
{
var result = await assets.MergeAsync(form);
if (result is null) return NotFound();
TempData["AssetMessage"] = $"{result.SourceName} merged into {result.TargetName}.";
return RedirectToAction(nameof(Details), new { id = result.TargetID });
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
}
}
var model = await assets.GetMergeAsync(form.SourceID);
if (model is null) return NotFound();
model.Form = form;
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(StoryAssetEditViewModel model)

View File

@ -1,110 +0,0 @@
using System.Globalization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using PlotLine.Services;
namespace PlotLine.Controllers;
[Authorize]
public sealed class StoryCalendarController(IStoryCalendarService calendar, ILogger<StoryCalendarController> logger) : Controller
{
public async Task<IActionResult> Index(int projectId, int? bookId, string? month, string? date)
{
if (projectId == 0)
{
return BadRequest("ProjectID is required.");
}
var selectedMonth = ParseMonth(month);
var selectedDate = ParseDate(date);
var model = await calendar.GetAsync(projectId, bookId, selectedMonth, selectedDate);
if (model is null)
{
return NotFound();
}
return IsAjaxRequest()
? PartialView("_StoryCalendarSurface", model)
: View(model);
}
[HttpGet]
public async Task<IActionResult> DayDetails(int projectId, int? bookId, string? date)
{
if (projectId == 0)
{
return BadRequest("ProjectID is required.");
}
var selectedDate = ParseDate(date);
if (!selectedDate.HasValue)
{
return BadRequest("Date is required.");
}
try
{
var model = await calendar.GetAsync(projectId, bookId, FirstOfMonth(selectedDate.Value), selectedDate);
return model is null ? NotFound() : PartialView("_StoryCalendarDayDetails", model.DayDetails);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to load Story Calendar day details for project {ProjectID} and date {StoryDate}.", projectId, date);
return StatusCode(StatusCodes.Status500InternalServerError, "The selected story date could not be loaded.");
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult ApplyFilters(int projectId, int? bookId, string? month)
{
return RedirectToAction(nameof(Index), new
{
projectId,
bookId,
month
});
}
private static DateOnly? ParseMonth(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateOnly.TryParseExact(
$"{value.Trim()}-01",
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed)
? new DateOnly(parsed.Year, parsed.Month, 1)
: null;
}
private static DateOnly? ParseDate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateOnly.TryParseExact(
value.Trim(),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed)
? parsed
: null;
}
private static DateOnly FirstOfMonth(DateOnly date)
=> new(date.Year, date.Month, 1);
private bool IsAjaxRequest()
=> string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
}

View File

@ -1,20 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
[Authorize]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class StoryPointController(IStoryPointContextService context) : Controller
{
[HttpGet]
public async Task<IActionResult> Index(int projectId, int? sceneId)
{
if (projectId <= 0) return BadRequest("ProjectID is required.");
var model = await context.GetAsync(projectId, sceneId);
if (model is null) return NotFound();
return string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase)
? PartialView("_Snapshot", model.Snapshot) : View(model);
}
}

View File

@ -1,213 +0,0 @@
using System.Data;
using System.Text.Json;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface ICharacterEnrichmentRepository
{
Task<CharacterEnrichmentRun> QueueAsync(CharacterEnrichmentQueueRequest request);
Task<CharacterEnrichmentRun> QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request);
Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId);
Task<CharacterEnrichmentRun?> ClaimNextAsync(int leaseMinutes);
Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId);
Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId);
Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId);
Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update);
Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList<CharacterEnrichmentCharacterResult> results);
Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs);
Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId);
Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId, int? characterId);
}
public sealed class CharacterEnrichmentRepository(ISqlConnectionFactory connectionFactory) : ICharacterEnrichmentRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<CharacterEnrichmentRun> QueueAsync(CharacterEnrichmentQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_Queue",
request,
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun> QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_QueueCharacter",
request,
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_Retry",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> ClaimNextAsync(int leaseMinutes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_ClaimNext",
new { LeaseMinutes = leaseMinutes },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_GetCurrentByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentRun>(
"dbo.CharacterEnrichmentRun_GetCurrentByCharacter",
new { CharacterID = characterId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CharacterEnrichmentCharacterStatus>(
"dbo.CharacterEnrichmentCharacterStatus_Get",
new { CharacterID = characterId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterEnrichmentRun_UpdateProgress",
update,
commandType: CommandType.StoredProcedure);
}
public async Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList<CharacterEnrichmentCharacterResult> results)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterEnrichmentRun_Complete",
new
{
request.CharacterEnrichmentRunID,
request.Model,
request.InputTokens,
request.OutputTokens,
request.TotalTokens,
request.DurationMs,
ResultsJson = JsonSerializer.Serialize(results, JsonOptions)
},
commandType: CommandType.StoredProcedure);
}
public async Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterEnrichmentRun_Fail",
new
{
CharacterEnrichmentRunID = runId,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
DurationMs = durationMs
},
commandType: CommandType.StoredProcedure);
}
public async Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId)
=> await BuildContextAsync(projectId, bookId, null);
public async Task<CharacterEnrichmentContext> BuildContextAsync(int projectId, int bookId, int? characterId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.CharacterEnrichment_ContextGet",
new { ProjectID = projectId, BookID = bookId, CharacterID = characterId },
commandType: CommandType.StoredProcedure);
var book = await result.ReadSingleAsync<ContextBookRow>();
var characters = (await result.ReadAsync<CharacterEnrichmentCharacterContext>()).ToList();
var aliases = (await result.ReadAsync<ContextAliasRow>()).ToList();
var scenes = (await result.ReadAsync<ContextSceneRow>()).ToList();
var aliasesByCharacter = aliases
.GroupBy(alias => alias.CharacterID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(alias => alias.Alias).ToList());
var scenesByCharacter = scenes
.GroupBy(scene => scene.CharacterID)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<CharacterEnrichmentSceneContext>)group.Select(scene => new CharacterEnrichmentSceneContext
{
SceneID = scene.SceneID,
ChapterID = scene.ChapterID,
ChapterNumber = scene.ChapterNumber,
ChapterTitle = scene.ChapterTitle,
SceneNumber = scene.SceneNumber,
SceneTitle = scene.SceneTitle,
SourceText = scene.SourceText
}).ToList());
return new CharacterEnrichmentContext
{
ProjectID = projectId,
BookID = bookId,
BookTitle = book.BookTitle,
StoryEra = book.StoryEra,
SeriesStartDate = book.SeriesStartDate,
Characters = characters.Select(character => new CharacterEnrichmentCharacterContext
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
BirthDate = character.BirthDate,
AgeAtSeriesStart = character.AgeAtSeriesStart,
Height = character.Height,
EyeColour = character.EyeColour,
DefaultDescription = character.DefaultDescription,
Aliases = aliasesByCharacter.GetValueOrDefault(character.CharacterID) ?? [],
Scenes = scenesByCharacter.GetValueOrDefault(character.CharacterID) ?? []
}).ToList()
};
}
private sealed class ContextBookRow
{
public string BookTitle { get; init; } = string.Empty;
public string? StoryEra { get; init; }
public DateTime? SeriesStartDate { get; init; }
}
private sealed class ContextAliasRow
{
public int CharacterID { get; init; }
public string Alias { get; init; } = string.Empty;
}
private sealed class ContextSceneRow
{
public int CharacterID { get; init; }
public int SceneID { get; init; }
public int ChapterID { get; init; }
public decimal ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public decimal SceneNumber { get; init; }
public string SceneTitle { get; init; } = string.Empty;
public string SourceText { get; init; } = string.Empty;
}
}

View File

@ -178,7 +178,6 @@ public interface IStoryIntelligenceNarrativeCandidateRepository
public interface IAssetRepository
{
Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId);
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId);
Task<StoryAsset?> GetAssetAsync(int storyAssetId);
@ -250,7 +249,6 @@ public interface ICharacterRepository
Task<IReadOnlyList<CharacterRelationship>> ListInitialRelationshipsByCharacterAsync(int characterId);
Task<CharacterRelationship?> GetRelationshipAsync(int characterRelationshipId);
Task<int> SaveRelationshipAsync(CharacterRelationship relationship);
Task<int> AcceptRelationshipDevelopmentAsync(RelationshipDevelopmentAcceptance proposal);
Task ArchiveRelationshipAsync(int characterRelationshipId);
Task<IReadOnlyList<RelationshipEvent>> ListRelationshipEventsBySceneAsync(int sceneId);
Task<IReadOnlyList<RelationshipEvent>> ListRelationshipEventsByCharacterAsync(int characterId);
@ -262,8 +260,6 @@ public interface ICharacterRepository
public interface ILocationRepository
{
Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId, int? userId);
Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId);
Task<LocationLookupData> GetLookupsAsync();
Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId);
Task<LocationItem?> GetAsync(int locationId);
@ -490,37 +486,6 @@ public sealed class TimelineSceneOverviewRow
public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository
{
public async Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId, int? userId)
{
using var connection = connectionFactory.CreateConnection();
try
{
await connection.ExecuteAsync("dbo.Location_Reparent",
new { ProjectID = projectId, LocationID = locationId, ParentLocationID = parentLocationId,
ExpectedParentLocationID = expectedParentLocationId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
catch (SqlException ex) when (ex.Number == 51000)
{
throw new InvalidOperationException(ex.Message, ex);
}
}
public async Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId)
{
using var connection = connectionFactory.CreateConnection();
try
{
return await connection.QuerySingleOrDefaultAsync<EntityMergeResult>(
"dbo.Location_Merge", new { SourceID = sourceId, TargetID = targetId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
catch (SqlException ex) when (ex.Number == 51000)
{
throw new InvalidOperationException(ex.Message, ex);
}
}
public async Task<LocationLookupData> GetLookupsAsync()
{
using var connection = connectionFactory.CreateConnection();
@ -3397,21 +3362,6 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository
{
public async Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId)
{
using var connection = connectionFactory.CreateConnection();
try
{
return await connection.QuerySingleOrDefaultAsync<EntityMergeResult>(
"dbo.StoryAsset_Merge", new { SourceID = sourceId, TargetID = targetId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
catch (SqlException ex) when (ex.Number == 51000)
{
throw new InvalidOperationException(ex.Message, ex);
}
}
public async Task<AssetLookupData> GetLookupsAsync(int? projectId = null)
{
using var connection = connectionFactory.CreateConnection();
@ -4127,13 +4077,6 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
commandType: CommandType.StoredProcedure);
}
public async Task<int> AcceptRelationshipDevelopmentAsync(RelationshipDevelopmentAcceptance proposal)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>("dbo.StoryIntelligenceRelationship_AcceptDevelopment", proposal,
commandType: CommandType.StoredProcedure);
}
public async Task<int> SaveRelationshipAsync(CharacterRelationship relationship)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -653,19 +653,13 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
for (var index = 0; index < orderedScenes.Count; index++)
{
var item = orderedScenes[index];
var existingScene = item.SceneID.HasValue
? existingScenes.FirstOrDefault(scene => scene.SceneID == item.SceneID.Value)
: existingScenes.Count > index ? existingScenes[index] : null;
var preserveExistingChronology = HasExistingChronology(existingScene);
var operation = "preparing scene";
try
{
operation = "saving scene";
var relativeTimeText = TrimOptionalText(
preserveExistingChronology
? existingScene?.RelativeTimeText
: item.RelativeTimeText ?? item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
200,
$"Scene {item.TemporarySceneNumber:N0} relative time",
commitWarnings);
@ -677,18 +671,18 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save",
new
{
SceneID = item.SceneID ?? existingScene?.SceneID,
SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null),
ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle,
Summary = item.Summary,
TimeModeID = preserveExistingChronology ? existingScene!.TimeModeID : item.TimeModeID,
StartDateTime = preserveExistingChronology ? existingScene!.StartDateTime : item.StartDateTime,
EndDateTime = preserveExistingChronology ? existingScene!.EndDateTime : item.EndDateTime,
DurationAmount = preserveExistingChronology ? existingScene!.DurationAmount : item.DurationAmount,
DurationUnitID = preserveExistingChronology ? existingScene!.DurationUnitID : item.DurationUnitID,
TimeModeID = request.TimeModeID,
StartDateTime = (DateTime?)null,
EndDateTime = (DateTime?)null,
DurationAmount = (decimal?)null,
DurationUnitID = (int?)null,
RelativeTimeText = relativeTimeText,
TimeConfidenceID = preserveExistingChronology ? existingScene!.TimeConfidenceID : item.TimeConfidenceID,
TimeConfidenceID = request.TimeConfidenceID,
ScenePurposeNotes = item.PurposeNotes,
SceneOutcomeNotes = item.OutcomeNotes,
RevisionStatusID = request.RevisionStatusID,
@ -881,14 +875,6 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
}
}
private static bool HasExistingChronology(Scene? scene)
=> scene is not null
&& (scene.StartDateTime.HasValue
|| scene.EndDateTime.HasValue
|| scene.DurationAmount.HasValue
|| scene.DurationUnitID.HasValue
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText));
private static string? TrimOptionalText(string? value, int maxLength, string label, ICollection<string> warnings)
{
if (string.IsNullOrWhiteSpace(value))

View File

@ -6,7 +6,6 @@ namespace PlotLine.Data;
public interface IStoryIntelligenceReviewCandidateRepository
{
Task RefreshPendingRelationshipsAsync(int bookId, int generationId, string payloadJson);
Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source);
Task<StoryIntelligenceReviewCandidateGeneration?> GetCurrentGenerationAsync(int bookId, string module);
Task<StoryIntelligenceReviewCandidateRow> SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request);
@ -16,19 +15,10 @@ public interface IStoryIntelligenceReviewCandidateRepository
Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListPageAsync(int bookId, string module, int pageNumber, int pageSize);
Task<StoryIntelligenceReviewCandidateGeneration?> MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request);
Task ResetBookAsync(int bookId);
Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListLocationsAsync(int bookId);
Task BackfillLocationSignificanceAsync(int bookId, string assessmentsJson);
}
public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceReviewCandidateRepository
{
public async Task RefreshPendingRelationshipsAsync(int bookId, int generationId, string payloadJson)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships",
new { BookID = bookId, GenerationID = generationId, PayloadJson = payloadJson }, commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source)
{
using var connection = connectionFactory.CreateConnection();
@ -130,20 +120,6 @@ public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFac
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListLocationsAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryIntelligenceReviewCandidateRow>(
"dbo.StoryIntelligenceLocationReview_List", new { BookID = bookId }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task BackfillLocationSignificanceAsync(int bookId, string assessmentsJson)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.StoryIntelligenceLocationReview_BackfillSignificance",
new { BookID = bookId, AssessmentsJson = assessmentsJson }, commandType: CommandType.StoredProcedure);
}
public async Task ResetBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -1,39 +0,0 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public sealed class StoryPointHistory
{
public IReadOnlyList<CharacterKnowledgeItem> Knowledge { get; init; } = [];
public IReadOnlyList<SceneAssetLocation> AssetLocations { get; init; } = [];
public IReadOnlyList<AssetCustodyEvent> Custody { get; init; } = [];
public IReadOnlyList<AssetCustodyCharacter> Custodians { get; init; } = [];
public IReadOnlyList<PlotThread> Threads { get; init; } = [];
public IReadOnlyList<ThreadEvent> ThreadEvents { get; init; } = [];
}
public interface IStoryPointRepository
{
Task<StoryPointHistory> GetHistoryAsync(int projectId, int sceneId);
}
public sealed class StoryPointRepository(ISqlConnectionFactory connections) : IStoryPointRepository
{
public async Task<StoryPointHistory> GetHistoryAsync(int projectId, int sceneId)
{
using var connection = connections.CreateConnection();
using var rows = await connection.QueryMultipleAsync("dbo.StoryPoint_GetHistory",
new { ProjectID = projectId, SceneID = sceneId }, commandType: CommandType.StoredProcedure);
return new StoryPointHistory
{
Knowledge = (await rows.ReadAsync<CharacterKnowledgeItem>()).ToList(),
AssetLocations = (await rows.ReadAsync<SceneAssetLocation>()).ToList(),
Custody = (await rows.ReadAsync<AssetCustodyEvent>()).ToList(),
Custodians = (await rows.ReadAsync<AssetCustodyCharacter>()).ToList(),
Threads = (await rows.ReadAsync<PlotThread>()).ToList(),
ThreadEvents = (await rows.ReadAsync<ThreadEvent>()).ToList()
};
}
}

View File

@ -556,13 +556,6 @@ The root object must not include scene-level thread candidates or continuity war
{
"characterA": "Mara",
"characterB": "Elias",
"proposalKind": "Development",
"factualRelationshipType": null,
"relationshipState": "Distrustful",
"isReciprocal": false,
"isKnownToOtherCharacter": true,
"intensity": 5,
"development": "Mara confronts Elias and starts doubting him.",
"relationshipSignal": "mistrust",
"evidence": "Mara questions Elias about what he is hiding.",
"confidence": 0.78
@ -570,7 +563,7 @@ The root object must not include scene-level thread candidates or continuity war
]
```
- Notes: These are scene proposals for author review. Proposal kind separates durable factual ties from newly revealed states and meaningful developments. A is the actor/feeling-holder and B the recipient; reciprocity and awareness require evidence. Intensity is emotional strength, never confidence. Omit routine repetition and co-presence. Legacy results may omit the new fields; review flags their missing direction. Neutral signal terms include `trust`, `mistrust`, `affection`, `fear`, `authority`, `secrecy`, `conflict`, `dependence`, `protection`, `rivalry` and `obligation`.
- Notes: These are evidence-backed scene-local signals, not final relationship records. Neutral signal terms include `trust`, `mistrust`, `affection`, `fear`, `authority`, `secrecy`, `conflict`, `dependence`, `protection`, `rivalry` and `obligation`.
### `knowledgeChanges`

View File

@ -268,15 +268,9 @@ Use neutral terms such as:
These are scene-local signals for later Relationship Intelligence. They are not final relationship records.
Relationship evidence must describe the two named participants, not other people in the scene. Routine authority, an examiner's reputation, anxiety, disagreement, or distrust do not establish rivalry: use rivalry only for demonstrated competition between the pair. Ordinary affection, compliments, flirtation, attraction, gifts, hugs, or an ambiguous kiss do not establish a romantic relationship. Include romance only with evidence of an existing relationship or a participant wanting a romantic relationship with the other. Preserve explicit brother/sister/sibling statements (including older/younger and named possessives), while excluding figurative "like a sister" language and discussion of someone else's sibling. Preserve explicit "best friend" wording and distinguish simple acquaintance from friendship. Do not invent a relationship from mere co-presence.
For each relationship signal, include exactly these properties: `characterA`, `characterB`, `relationshipSignal`, `evidence`, `confidence`.
For each proposal include `characterA`, `characterB`, `proposalKind`, `factualRelationshipType`, `relationshipState`, `isReciprocal`, `isKnownToOtherCharacter`, `intensity`, `development`, `relationshipSignal`, `evidence`, `confidence`.
Analyse only the current scene. Propose a meaningful interaction, newly revealed state, or change, never an event solely for co-presence or routine repetition. `proposalKind` is Factual for durable kinship facts, State for a meaningful newly revealed state, or Development for an interaction/change. Keep factual ties separate from emotional states: siblings can become distrustful. `factualRelationshipType` is null except for factual ties (e.g. Parent, Child, Sibling).
Character A is the person whose feeling/action is directed towards character B. `isReciprocal` is true only when evidence establishes the same state in both directions. A may feel attraction while B feels nothing; do not infer mutual romance. Use separate proposals when opposite directions differ. Parent means A is B's parent; Child means A is B's child. Sibling is reciprocal. `isKnownToOtherCharacter` is true only if B knows A's feeling/state.
`relationshipState` describes this scene's resulting state, using Unknown, Positive, Neutral, Negative, Tense, Friendly, Romantic, Sexual Tension, In Love, Distrustful, Hostile, Protective, Estranged, Broken, or Reconciled when supported. Attraction can be unilateral and need not mean an established couple. `intensity` is emotional strength 1–10, or null if unsupported; it is never confidence. `development` describes the meaningful interaction/change and direction; `evidence` cites scene text. Confidence measures certainty only. Do not project later developments into earlier scenes. Multiple meaningful developments in one scene may be separate proposals.
Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`.
## Knowledge Change Rules

View File

@ -320,15 +320,11 @@ Use neutral terms such as:
These are scene-local signals for later Relationship Intelligence. They are not final relationship records.
Relationship evidence must describe the two named participants, not other people in the scene. Routine authority, an examiner's reputation, anxiety, disagreement, or distrust do not establish rivalry: use rivalry only for demonstrated competition between the pair. Ordinary affection, compliments, flirtation, attraction, gifts, hugs, or an ambiguous kiss do not establish a romantic relationship. Include romance only with evidence of an existing relationship or a participant wanting a romantic relationship with the other. Preserve explicit brother/sister/sibling statements (including older/younger and named possessives), while excluding figurative "like a sister" language and discussion of someone else's sibling. Preserve explicit "best friend" wording and distinguish simple acquaintance from friendship. Do not invent a relationship from mere co-presence.
For each relationship signal, include exactly these properties: `characterA`, `characterB`, `relationshipSignal`, `evidence`, `confidence`.
For each proposal include `characterA`, `characterB`, `proposalKind`, `factualRelationshipType`, `relationshipState`, `isReciprocal`, `isKnownToOtherCharacter`, `intensity`, `development`, `relationshipSignal`, `evidence`, `confidence`.
Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`.
Analyse only the current scene. Propose a meaningful interaction, newly revealed state, or change, never an event solely for co-presence or routine repetition. `proposalKind` is Factual for durable kinship facts, State for a meaningful newly revealed state, or Development for an interaction/change. Keep factual ties separate from emotional states: siblings can become distrustful. `factualRelationshipType` is null except for factual ties (e.g. Parent, Child, Sibling).
Character A is the person whose feeling/action is directed towards character B. `isReciprocal` is true only when evidence establishes the same state in both directions. A may feel attraction while B feels nothing; do not infer mutual romance. Use separate proposals when opposite directions differ. Parent means A is B's parent; Child means A is B's child. Sibling is reciprocal. `isKnownToOtherCharacter` is true only if B knows A's feeling/state.
`relationshipState` describes this scene's resulting state, using Unknown, Positive, Neutral, Negative, Tense, Friendly, Romantic, Sexual Tension, In Love, Distrustful, Hostile, Protective, Estranged, Broken, or Reconciled when supported. Attraction can be unilateral and need not mean an established couple. `intensity` is emotional strength 1–10, or null if unsupported; it is never confidence. `development` describes the meaningful interaction/change and direction; `evidence` cites scene text. Confidence measures certainty only. Do not project later developments into earlier scenes. Multiple meaningful developments in one scene may be separate proposals.
Preserve relationship progression evidence without flattening it. Repeated trust, hugs, gifts, shared social plans and familiar support can be friendship evidence. Romantic attraction, courtship, proposal or engagement needs explicit romantic evidence. Do not mark an early scene as a final state merely because adjacent or later context suggests a later progression.
## Knowledge Change Rules
@ -382,30 +378,12 @@ Report chronology clues such as:
Preserve vague wording when the manuscript is vague.
Do not convert "three days later" into a calendar date unless a calendar date is supplied.
For each timeline clue, include exactly these properties: `clue`, `relativeOrder`, `absoluteDate`, `evidence`, `confidence`.
Use `relativeOrder: null` when no relative ordering is supported. Use `absoluteDate: null` when no exact date is supplied.
## Temporal Analysis Rules
Also return one `temporalAnalysis` object for the scene's primary story time.
Use the current scene text first. Use `sceneContext.rollingTemporalContext` only to interpret relative phrases such as "later that afternoon", "after midnight", "following morning", "three days later" or "the following Friday".
Code will do deterministic calendar arithmetic. Your job is to extract what the prose says:
- put explicit dates in `absoluteDate`, preserving the manuscript wording or ISO-like form when present;
- put explicit clock times in `exactTime`;
- put broad time words such as "morning", "afternoon", "evening", "night", "dawn", "dusk" or "midnight" in `partOfDay`;
- put transition words such as `same`, `later`, `next`, `following`, `after`, `before` or `elapsed` in `relativeTransition`;
- put exact numeric relative amounts in `relativeAmount` and the unit in `relativeUnit`;
- put a named weekday in `targetDayOfWeek`;
- set `crossesMidnight` when the scene explicitly moves to the next calendar day after midnight;
- keep `evidence` concise and scene-backed;
- use `unresolvedReason` when the phrase is intentionally vague or lacks enough context.
Do not invent a year, date, clock time, duration or weekday. Do not store broad phrases like "afternoon" as fake exact times. For "a few days later", keep the relative phrase and set `unresolvedReason` rather than choosing a number. If no temporal signal is present, set nullable fields to `null`, confidence to `0.0`, and `unresolvedReason` to `No temporal signal in scene text.`
## Questions Raised Rules
Report questions the scene creates or sharpens.

View File

@ -14,7 +14,6 @@ public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
ICharacterEnrichmentService characterEnrichment,
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
{
public override async Task OnConnectedAsync()
@ -67,41 +66,8 @@ public sealed class StoryIntelligenceHub(
return await globalProgress.GetForCurrentUserAsync();
}
public async Task<CharacterEnrichmentProgressEvent?> WatchCharacterEnrichment(int bookId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
var run = await characterEnrichment.GetCurrentAsync(bookId);
return run is null || run.UserID != userId ? null : ToCharacterEnrichmentProgress(run);
}
public async Task<CharacterEnrichmentProgressEvent?> WatchCharacterEnrichmentForCharacter(int characterId)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
var run = await characterEnrichment.GetCurrentForCharacterAsync(characterId, userId);
return run is null || run.UserID != userId ? null : ToCharacterEnrichmentProgress(run);
}
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static CharacterEnrichmentProgressEvent ToCharacterEnrichmentProgress(CharacterEnrichmentRun run)
=> new()
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
CharacterID = run.CharacterID,
Status = run.Status,
TotalCharacters = run.TotalCharacters,
ProcessedCharacters = run.ProcessedCharacters,
CurrentStage = run.CurrentStage ?? string.Empty,
CurrentMessage = run.CurrentMessage ?? string.Empty,
ErrorMessage = run.ErrorMessage,
UpdatedUtc = run.UpdatedUtc
};
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)
{
var completedScenes = run.CompletedScenes ?? 0;

View File

@ -1,182 +0,0 @@
using System.Text.Json;
namespace PlotLine.Models;
public static class CharacterEnrichmentStatuses
{
public const string Queued = "Queued";
public const string Running = "Running";
public const string Completed = "Completed";
public const string Failed = "Failed";
public const string Superseded = "Superseded";
}
public sealed class CharacterEnrichmentRun
{
public int CharacterEnrichmentRunID { get; set; }
public int ProjectID { get; set; }
public int BookID { get; set; }
public int UserID { get; set; }
public int? CharacterID { get; set; }
public int? StoryIntelligenceBookPipelineID { get; set; }
public string CharacterResolutionVersion { get; set; } = string.Empty;
public string Status { get; set; } = CharacterEnrichmentStatuses.Queued;
public int TotalCharacters { get; set; }
public int ProcessedCharacters { get; set; }
public string? CurrentStage { get; set; }
public string? CurrentMessage { get; set; }
public DateTime QueuedUtc { get; set; }
public DateTime? StartedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public DateTime? FailedUtc { get; set; }
public int AttemptCount { get; set; }
public string? Model { get; set; }
public int? InputTokens { get; set; }
public int? OutputTokens { get; set; }
public int? TotalTokens { get; set; }
public long? DurationMs { get; set; }
public string? ErrorMessage { get; set; }
public string? ErrorDetail { get; set; }
public DateTime UpdatedUtc { get; set; }
public bool WasCoalesced { get; set; }
}
public sealed class CharacterEnrichmentQueueRequest
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public int UserID { get; init; }
public int? StoryIntelligenceBookPipelineID { get; init; }
public string CharacterResolutionVersion { get; init; } = string.Empty;
}
public sealed class CharacterEnrichmentCharacterQueueRequest
{
public int CharacterID { get; init; }
public int UserID { get; init; }
public int? BookID { get; init; }
}
public sealed class CharacterEnrichmentCharacterStatus
{
public int CharacterID { get; init; }
public int? BookID { get; init; }
public int? CharacterEnrichmentRunID { get; init; }
public string Status { get; init; } = "Not analysed";
public string? CurrentStage { get; init; }
public string? CurrentMessage { get; init; }
public DateTime? LastAnalysedUtc { get; init; }
public string? ErrorMessage { get; init; }
public string? Summary { get; init; }
public string? DateOfBirth { get; init; }
public string? AgeAtStartOfSeries { get; init; }
public string? Sex { get; init; }
public string? Height { get; init; }
public string? EyeColour { get; init; }
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
public bool IsActive => Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
public bool IsCompleted => Status == CharacterEnrichmentStatuses.Completed;
}
public sealed class CharacterEnrichmentProgressUpdate
{
public int CharacterEnrichmentRunID { get; init; }
public int TotalCharacters { get; init; }
public int ProcessedCharacters { get; init; }
public string CurrentStage { get; init; } = string.Empty;
public string CurrentMessage { get; init; } = string.Empty;
}
public sealed class CharacterEnrichmentCompletionRequest
{
public int CharacterEnrichmentRunID { get; init; }
public string Model { get; init; } = string.Empty;
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? DurationMs { get; init; }
}
public sealed class CharacterEnrichmentContext
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public string BookTitle { get; init; } = string.Empty;
public string? StoryEra { get; init; }
public DateTime? SeriesStartDate { get; init; }
public IReadOnlyList<CharacterEnrichmentCharacterContext> Characters { get; init; } = [];
}
public sealed class CharacterEnrichmentCharacterContext
{
public int CharacterID { get; init; }
public string CharacterName { get; init; } = string.Empty;
public DateTime? BirthDate { get; init; }
public int? AgeAtSeriesStart { get; init; }
public string? Height { get; init; }
public string? EyeColour { get; init; }
public string? DefaultDescription { get; init; }
public IReadOnlyList<string> Aliases { get; init; } = [];
public IReadOnlyList<CharacterEnrichmentSceneContext> Scenes { get; init; } = [];
}
public sealed class CharacterEnrichmentSceneContext
{
public int SceneID { get; init; }
public int ChapterID { get; init; }
public decimal ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public decimal SceneNumber { get; init; }
public string SceneTitle { get; init; } = string.Empty;
public string SourceText { get; init; } = string.Empty;
}
public sealed class CharacterEnrichmentResult
{
public string SchemaVersion { get; init; } = "1";
public IReadOnlyList<CharacterEnrichmentCharacterResult> Characters { get; init; } = [];
public static CharacterEnrichmentResult FromJson(string json)
=> JsonSerializer.Deserialize<CharacterEnrichmentResult>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
public sealed class CharacterEnrichmentCharacterResult
{
public int CharacterID { get; init; }
public string Summary { get; init; } = string.Empty;
public decimal Confidence { get; init; }
public CharacterEnrichmentFactResult DateOfBirth { get; init; } = new();
public CharacterEnrichmentFactResult AgeAtStartOfSeries { get; init; } = new();
public CharacterEnrichmentFactResult Sex { get; init; } = new();
public CharacterEnrichmentFactResult Height { get; init; } = new();
public CharacterEnrichmentFactResult EyeColour { get; init; } = new();
public IReadOnlyList<int> EvidenceSceneIDs { get; init; } = [];
}
public sealed class CharacterEnrichmentFactResult
{
public string? Value { get; init; }
public decimal Confidence { get; init; }
public string? Evidence { get; init; }
public int? SceneID { get; init; }
}
public sealed class CharacterEnrichmentProgressEvent
{
public int CharacterEnrichmentRunID { get; init; }
public int UserID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int? CharacterID { get; init; }
public string Status { get; init; } = string.Empty;
public int TotalCharacters { get; init; }
public int ProcessedCharacters { get; init; }
public string CurrentStage { get; init; } = string.Empty;
public string CurrentMessage { get; init; } = string.Empty;
public string? ErrorMessage { get; init; }
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
public bool IsActive => Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
public bool IsCompleted => Status == CharacterEnrichmentStatuses.Completed;
}

View File

@ -2665,20 +2665,3 @@ public sealed class LookupData
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; init; } = [];
public IReadOnlyList<ChapterPurposeType> ChapterPurposeTypes { get; init; } = [];
}
public sealed class RelationshipDevelopmentAcceptance
{
public int ProjectID { get; set; }
public int BookID { get; set; }
public int SceneID { get; set; }
public string SourceReviewKey { get; set; } = string.Empty;
public int CharacterAID { get; set; }
public int CharacterBID { get; set; }
public int RelationshipTypeID { get; set; }
public int RelationshipStateID { get; set; }
public bool IsPermanent { get; set; }
public bool IsReciprocal { get; set; }
public bool IsKnownToOtherCharacter { get; set; }
public int? Intensity { get; set; }
public string Description { get; set; } = string.Empty;
}

View File

@ -1,9 +0,0 @@
namespace PlotLine.Models;
public sealed class EntityMergeResult
{
public int ProjectID { get; set; }
public int TargetID { get; set; }
public string SourceName { get; set; } = string.Empty;
public string TargetName { get; set; } = string.Empty;
}

View File

@ -18,7 +18,6 @@ public sealed class SceneIntelligenceScene
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
public List<SceneIntelligenceKnowledgeChange>? KnowledgeChanges { get; init; }
public List<SceneIntelligenceTimelineClue>? TimelineClues { get; init; }
public SceneIntelligenceTemporalAnalysis? TemporalAnalysis { get; init; }
public List<SceneIntelligenceNarrativeArc>? NarrativeArcs { get; init; }
public List<SceneIntelligenceQuestionRaised>? QuestionsRaised { get; init; }
public List<SceneIntelligenceQuestionAnswered>? QuestionsAnswered { get; init; }
@ -165,13 +164,6 @@ public sealed class SceneIntelligenceRelationship
{
public string? CharacterA { get; init; }
public string? CharacterB { get; init; }
public string? ProposalKind { get; init; }
public string? FactualRelationshipType { get; init; }
public string? RelationshipState { get; init; }
public bool? IsReciprocal { get; init; }
public bool? IsKnownToOtherCharacter { get; init; }
public int? Intensity { get; init; }
public string? Development { get; init; }
public string? RelationshipSignal { get; init; }
public string? Evidence { get; init; }
public decimal? Confidence { get; init; }
@ -206,24 +198,6 @@ public sealed class SceneIntelligenceTimelineClue
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceTemporalAnalysis
{
public string? AbsoluteDate { get; init; }
public string? ExactTime { get; init; }
public string? PartOfDay { get; init; }
public string? RelativeTransition { get; init; }
public int? RelativeAmount { get; init; }
public string? RelativeUnit { get; init; }
public string? TargetDayOfWeek { get; init; }
public bool? CrossesMidnight { get; init; }
public string? Evidence { get; init; }
public decimal? Confidence { get; init; }
public string? UnresolvedReason { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceQuestionRaised
{
public string? Question { get; init; }

View File

@ -169,8 +169,6 @@ public sealed class StoryIntelligenceReviewCentreSummary
public int? CharacterPendingCount { get; init; }
public string? LocationCandidateGenerationStatus { get; init; }
public int? LocationCandidateCount { get; init; }
public int LocationSignificantCount { get; set; }
public int LocationUncertainCount { get; set; }
public int? LocationPendingCount { get; init; }
public string? AssetCandidateGenerationStatus { get; init; }
public int? AssetCandidateCount { get; init; }
@ -634,13 +632,6 @@ public sealed class StoryIntelligenceSceneImportItem
public string? PurposeNotes { get; init; }
public string? OutcomeNotes { get; init; }
public string? ImportNoteText { get; init; }
public int TimeModeID { get; init; }
public DateTime? StartDateTime { get; init; }
public DateTime? EndDateTime { get; init; }
public decimal? DurationAmount { get; init; }
public int? DurationUnitID { get; init; }
public string? RelativeTimeText { get; init; }
public int TimeConfidenceID { get; init; }
public string AnalysisStatus { get; init; } = "Ready";
public bool HasWarnings { get; init; }
}

View File

@ -153,7 +153,6 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceRepository, WholeBookPlotIntelligenceRepository>();
builder.Services.AddScoped<ICharacterEnrichmentRepository, CharacterEnrichmentRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
builder.Services.AddScoped<INameIntelligenceRepository, NameIntelligenceRepository>();
@ -175,9 +174,6 @@ public class Program
builder.Services.AddScoped<IStoryStateService, StoryStateService>();
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<IStoryCalendarService, StoryCalendarService>();
builder.Services.AddScoped<IStoryPointRepository, StoryPointRepository>();
builder.Services.AddScoped<IStoryPointContextService, StoryPointContextService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
builder.Services.AddScoped<IPlotService, PlotService>();
@ -240,7 +236,6 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
builder.Services.AddScoped<ICharacterEnrichmentService, CharacterEnrichmentService>();
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();

View File

@ -9,29 +9,8 @@ public sealed class CharacterAgeDisplayResult
public string? DisplayText { get; init; }
}
public sealed record CharacterProfileAgeDisplayResult(string DisplayText, string Context, Scene? ReferenceScene);
public static class CharacterAgeDisplayService
{
public static CharacterProfileAgeDisplayResult GetProfileAge(Character character,
IReadOnlyList<Scene> orderedScenes, IReadOnlyList<SceneCharacter> appearances)
{
var appearanceIds = appearances.Where(x => x.CharacterID == character.CharacterID).Select(x => x.SceneID).ToHashSet();
var reference = character.AgeReferenceSceneID.HasValue
? orderedScenes.FirstOrDefault(x => x.SceneID == character.AgeReferenceSceneID)
: null;
if (reference is null || !ResolveEffectiveSceneDate(reference.TimeModeName, reference.StartDateTime).HasValue)
reference = orderedScenes.FirstOrDefault(x => appearanceIds.Contains(x.SceneID)
&& ResolveEffectiveSceneDate(x.TimeModeName, x.StartDateTime).HasValue
&& (!character.BirthDate.HasValue || x.StartDateTime!.Value.Date >= character.BirthDate.Value.Date));
var age = GetAgeForScene(character, reference?.TimeModeName, reference?.StartDateTime);
return new CharacterProfileAgeDisplayResult(age.DisplayText ?? "Unknown",
age.HasExactAge ? (reference?.SceneID == character.AgeReferenceSceneID ? "At age reference scene" : "At first dated appearance")
: character.AgeAtSeriesStart.HasValue ? "Approximate age at series start"
: character.BirthDate.HasValue ? "A dated story scene is needed to calculate age." : "No birth date or approximate age recorded.",
age.HasExactAge ? reference : null);
}
public static CharacterAgeDisplayResult GetAgeForScene(SceneCharacter character, string? sceneTimeModeName, DateTime? sceneStartDateTime)
{
var effectiveDate = ResolveEffectiveSceneDate(sceneTimeModeName, sceneStartDateTime);

View File

@ -1,721 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface ICharacterEnrichmentService
{
Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId);
Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null);
Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId);
Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId);
Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId);
Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId);
Task<bool> ProcessNextAsync(CancellationToken cancellationToken);
string BuildPromptForTest(CharacterEnrichmentContext context);
StoryIntelligenceResponseContract BuildResponseContractForTest();
}
public sealed class CharacterEnrichmentService(
ICharacterEnrichmentRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryIntelligenceClient client,
IOptions<StoryIntelligenceOptions> options,
IStoryIntelligenceProgressNotifier notifier,
ILogger<CharacterEnrichmentService> logger) : ICharacterEnrichmentService
{
private const string PromptVersion = "Character-Enrichment-V1";
private const int BatchSize = 8;
private const int MaxPromptCharacters = 350_000;
private const int MainCharacterPromptCharacters = 175_000;
private const int MinimumSingleCharacterScenes = 8;
private const decimal SummaryMinimumConfidence = 0.65m;
private const decimal FactMinimumConfidence = 0.9m;
private static readonly Regex LeadingAgeRegex = new(@"\b(?<age>\d{1,3})\b", RegexOptions.Compiled);
private static readonly Regex BirthdayAgeDateRegex = new(@"\b(?<age>\d{1,3})(?:st|nd|rd|th)?\s+(?:birthday|on)\s+(?:on\s+)?(?<date>\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]+\s+\d{4})", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly string[] DateFormats =
[
"d MMMM yyyy",
"dd MMMM yyyy",
"d MMM yyyy",
"dd MMM yyyy",
"yyyy-MM-dd"
];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly StoryIntelligenceOptions settings = options.Value;
public async Task<CharacterEnrichmentRun?> QueueAfterCharacterResolutionAsync(int projectId, int bookId, int userId)
{
var pipeline = await pipelines.GetByBookForUserAsync(bookId, userId);
return await repository.QueueAsync(new CharacterEnrichmentQueueRequest
{
ProjectID = projectId,
BookID = bookId,
UserID = userId,
StoryIntelligenceBookPipelineID = pipeline?.StoryIntelligenceBookPipelineID,
CharacterResolutionVersion = $"CharacterImport:{bookId}"
});
}
public async Task<CharacterEnrichmentRun?> QueueForCharacterAsync(int characterId, int userId, int? bookId = null)
{
var run = await repository.QueueCharacterAsync(new CharacterEnrichmentCharacterQueueRequest
{
CharacterID = characterId,
UserID = userId,
BookID = bookId
});
if (run.WasCoalesced)
{
logger.LogInformation(
"Manual character enrichment request ignored because an active run already exists. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
run.Status);
}
else
{
logger.LogInformation(
"Manual character enrichment queued. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Status={Status}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
run.Status);
}
return run;
}
public Task<CharacterEnrichmentRun?> RetryAsync(int bookId, int userId)
=> repository.RetryAsync(bookId, userId);
public Task<CharacterEnrichmentRun?> GetCurrentAsync(int bookId)
=> repository.GetCurrentAsync(bookId);
public Task<CharacterEnrichmentRun?> GetCurrentForCharacterAsync(int characterId, int userId)
=> repository.GetCurrentForCharacterAsync(characterId, userId);
public Task<CharacterEnrichmentCharacterStatus?> GetCharacterStatusAsync(int characterId, int userId)
=> repository.GetCharacterStatusAsync(characterId, userId);
public async Task<bool> ProcessNextAsync(CancellationToken cancellationToken)
{
var run = await repository.ClaimNextAsync(Math.Max(15, settings.ClaimLeaseMinutes));
if (run is null)
{
return false;
}
var stopwatch = Stopwatch.StartNew();
var results = new List<CharacterEnrichmentCharacterResult>();
var inputTokens = 0;
var outputTokens = 0;
var model = settings.EffectiveWholeBookPlotSynthesisModel;
try
{
logger.LogInformation(
"Character enrichment starting. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID);
await PublishProgressAsync(run, 0, 0, "Preparing character analysis", "Preparing character analysis...", cancellationToken);
var context = await repository.BuildContextAsync(run.ProjectID, run.BookID, run.CharacterID);
var characters = context.Characters
.Where(character => character.Scenes.Count > 0)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
logger.LogInformation(
"Character enrichment context prepared. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
characters.Count);
await PublishProgressAsync(run, characters.Count, 0, "Analysing characters", "Analysing characters...", cancellationToken);
foreach (var batchContext in BuildPromptBatches(context, characters, run))
{
cancellationToken.ThrowIfCancellationRequested();
var prompt = BuildPrompt(batchContext);
if (prompt.Length > MaxPromptCharacters)
{
throw new InvalidOperationException($"Character enrichment prompt would exceed the configured safe context limit. PromptCharacters={prompt.Length:N0} Limit={MaxPromptCharacters:N0}. No manuscript text was silently truncated.");
}
logger.LogInformation(
"Character enrichment batch starting. RunID={RunID} CharacterID={CharacterID} BatchCharacters={BatchCharacters} PromptCharacters={PromptCharacters} SceneCount={SceneCount} Processed={Processed}/{Total}",
run.CharacterEnrichmentRunID,
run.CharacterID,
batchContext.Characters.Count,
prompt.Length,
batchContext.Characters.Sum(character => character.Scenes.Count),
results.Count,
characters.Count);
await PublishProgressAsync(run, characters.Count, results.Count, "Creating summaries and extracting character details", "Creating summaries and extracting character details...", cancellationToken);
var clientResult = await client.ExecutePromptAsync(
prompt,
PromptVersion,
cancellationToken,
settings.EffectiveWholeBookPlotSynthesisModel,
settings.WholeBookPlotSynthesisMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens),
responseContract: CharacterEnrichmentStructuredOutputSchema.Contract);
model = clientResult.Model;
inputTokens += clientResult.InputTokens ?? 0;
outputTokens += clientResult.OutputTokens ?? 0;
var parsed = CharacterEnrichmentResult.FromJson(ExtractOutputText(clientResult.RawResponseText));
results.AddRange(FilterResults(parsed.Characters, batchContext.Characters.Select(character => character.CharacterID).ToHashSet()));
await PublishProgressAsync(run, characters.Count, Math.Min(characters.Count, results.Count), "Saving character analysis", "Saving character analysis...", cancellationToken);
}
stopwatch.Stop();
await repository.CompleteAsync(new CharacterEnrichmentCompletionRequest
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
Model = model,
InputTokens = inputTokens == 0 ? null : inputTokens,
OutputTokens = outputTokens == 0 ? null : outputTokens,
TotalTokens = inputTokens + outputTokens == 0 ? null : inputTokens + outputTokens,
DurationMs = stopwatch.ElapsedMilliseconds
}, results);
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Completed, results.Count, results.Count, "Complete", "Character summaries are ready."));
logger.LogInformation(
"Character enrichment completed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} Characters={CharacterCount} DurationMs={DurationMs}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
run.CharacterID,
results.Count,
stopwatch.ElapsedMilliseconds);
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
stopwatch.Stop();
await repository.FailAsync(run.CharacterEnrichmentRunID, ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds);
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Failed, run.TotalCharacters, run.ProcessedCharacters, "Failed", "Character enrichment failed.", ex.Message));
logger.LogError(ex, "Character enrichment failed. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID}", run.CharacterEnrichmentRunID, run.ProjectID, run.BookID, run.CharacterID);
return true;
}
}
public string BuildPromptForTest(CharacterEnrichmentContext context)
=> BuildPrompt(context);
public StoryIntelligenceResponseContract BuildResponseContractForTest()
=> CharacterEnrichmentStructuredOutputSchema.Contract;
private IReadOnlyList<CharacterEnrichmentContext> BuildPromptBatches(
CharacterEnrichmentContext context,
IReadOnlyList<CharacterEnrichmentCharacterContext> characters,
CharacterEnrichmentRun run)
{
var batches = new List<CharacterEnrichmentContext>();
var current = new List<CharacterEnrichmentCharacterContext>();
foreach (var character in characters)
{
var single = BuildContextForCharacters(context, [character]);
var singlePromptLength = BuildPrompt(single).Length;
if (singlePromptLength > MaxPromptCharacters)
{
var restrained = RestrainSingleCharacterContext(context, character, run, singlePromptLength);
AddCurrentBatch();
batches.Add(restrained);
continue;
}
if (singlePromptLength >= MainCharacterPromptCharacters)
{
AddCurrentBatch();
batches.Add(single);
continue;
}
var candidate = BuildContextForCharacters(context, current.Concat([character]).ToList());
if (current.Count > 0 && (current.Count >= BatchSize || BuildPrompt(candidate).Length > MaxPromptCharacters))
{
AddCurrentBatch();
}
current.Add(character);
}
AddCurrentBatch();
return batches;
void AddCurrentBatch()
{
if (current.Count == 0)
{
return;
}
batches.Add(BuildContextForCharacters(context, current.ToList()));
current.Clear();
}
}
private CharacterEnrichmentContext RestrainSingleCharacterContext(
CharacterEnrichmentContext context,
CharacterEnrichmentCharacterContext character,
CharacterEnrichmentRun run,
int originalPromptLength)
{
var scenes = SelectRepresentativeScenes(character.Scenes, character.Scenes.Count).ToList();
while (scenes.Count > MinimumSingleCharacterScenes)
{
var candidate = BuildContextForCharacters(context, [CopyCharacter(character, scenes)]);
if (BuildPrompt(candidate).Length <= MaxPromptCharacters)
{
logger.LogWarning(
"Character enrichment constrained an evidence-heavy character to fit the safe context limit. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} CharacterName={CharacterName} OriginalPromptCharacters={OriginalPromptCharacters} FinalPromptCharacters={FinalPromptCharacters} OriginalScenes={OriginalScenes} IncludedScenes={IncludedScenes} Limit={Limit}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
character.CharacterID,
character.CharacterName,
originalPromptLength,
BuildPrompt(candidate).Length,
character.Scenes.Count,
scenes.Count,
MaxPromptCharacters);
return candidate;
}
scenes = SelectRepresentativeScenes(scenes, Math.Max(MinimumSingleCharacterScenes, scenes.Count - 4)).ToList();
}
var minimum = BuildContextForCharacters(context, [CopyCharacter(character, scenes)]);
var minimumPromptLength = BuildPrompt(minimum).Length;
if (minimumPromptLength <= MaxPromptCharacters)
{
logger.LogWarning(
"Character enrichment constrained an evidence-heavy character to the minimum scene set. RunID={RunID} ProjectID={ProjectID} BookID={BookID} CharacterID={CharacterID} CharacterName={CharacterName} OriginalPromptCharacters={OriginalPromptCharacters} FinalPromptCharacters={FinalPromptCharacters} OriginalScenes={OriginalScenes} IncludedScenes={IncludedScenes} Limit={Limit}",
run.CharacterEnrichmentRunID,
run.ProjectID,
run.BookID,
character.CharacterID,
character.CharacterName,
originalPromptLength,
minimumPromptLength,
character.Scenes.Count,
scenes.Count,
MaxPromptCharacters);
return minimum;
}
throw new InvalidOperationException($"Character enrichment prompt would exceed the configured safe context limit for character {character.CharacterID} even when limited to {scenes.Count:N0} whole scenes. PromptCharacters={minimumPromptLength:N0} Limit={MaxPromptCharacters:N0}. No manuscript text was silently truncated.");
}
private static CharacterEnrichmentContext BuildContextForCharacters(
CharacterEnrichmentContext context,
IReadOnlyList<CharacterEnrichmentCharacterContext> characters)
=> new()
{
ProjectID = context.ProjectID,
BookID = context.BookID,
BookTitle = context.BookTitle,
StoryEra = context.StoryEra,
SeriesStartDate = context.SeriesStartDate,
Characters = characters
};
private static CharacterEnrichmentCharacterContext CopyCharacter(
CharacterEnrichmentCharacterContext character,
IReadOnlyList<CharacterEnrichmentSceneContext> scenes)
=> new()
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
BirthDate = character.BirthDate,
AgeAtSeriesStart = character.AgeAtSeriesStart,
Height = character.Height,
EyeColour = character.EyeColour,
DefaultDescription = character.DefaultDescription,
Aliases = character.Aliases,
Scenes = scenes
};
private static IReadOnlyList<CharacterEnrichmentSceneContext> SelectRepresentativeScenes(
IReadOnlyList<CharacterEnrichmentSceneContext> scenes,
int maxScenes)
{
if (scenes.Count <= maxScenes)
{
return scenes;
}
maxScenes = Math.Clamp(maxScenes, 1, scenes.Count);
var selected = new SortedDictionary<int, CharacterEnrichmentSceneContext>();
var firstCount = Math.Min(scenes.Count, Math.Min(12, Math.Max(1, maxScenes / 3)));
var lastCount = Math.Min(scenes.Count - firstCount, Math.Min(8, Math.Max(0, maxScenes / 4)));
for (var index = 0; index < firstCount; index++)
{
selected[index] = scenes[index];
}
for (var index = scenes.Count - lastCount; index < scenes.Count; index++)
{
if (index >= 0)
{
selected[index] = scenes[index];
}
}
var remaining = maxScenes - selected.Count;
if (remaining > 0)
{
var start = firstCount;
var endExclusive = scenes.Count - lastCount;
var span = Math.Max(0, endExclusive - start);
for (var slot = 1; slot <= remaining && span > 0; slot++)
{
var offset = (int)Math.Round(slot * (span - 1) / (double)(remaining + 1), MidpointRounding.AwayFromZero);
selected.TryAdd(start + offset, scenes[start + offset]);
}
}
for (var index = 0; selected.Count < maxScenes && index < scenes.Count; index++)
{
selected.TryAdd(index, scenes[index]);
}
return selected.Values.ToList();
}
private async Task PublishProgressAsync(CharacterEnrichmentRun run, int total, int processed, string stage, string message, CancellationToken cancellationToken)
{
await repository.UpdateProgressAsync(new CharacterEnrichmentProgressUpdate
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
TotalCharacters = total,
ProcessedCharacters = processed,
CurrentStage = stage,
CurrentMessage = message
});
await notifier.PublishCharacterEnrichmentAsync(ToProgress(run, CharacterEnrichmentStatuses.Running, total, processed, stage, message));
}
private static IReadOnlyList<CharacterEnrichmentCharacterResult> FilterResults(
IReadOnlyList<CharacterEnrichmentCharacterResult> results,
HashSet<int> allowedCharacterIds)
=> results
.Where(result => allowedCharacterIds.Contains(result.CharacterID))
.Select(result => new CharacterEnrichmentCharacterResult
{
CharacterID = result.CharacterID,
Summary = Clamp(result.Confidence) >= SummaryMinimumConfidence ? Clean(result.Summary, 2500) : string.Empty,
Confidence = Clamp(result.Confidence),
DateOfBirth = NormaliseDateOfBirth(result.DateOfBirth, result.AgeAtStartOfSeries),
AgeAtStartOfSeries = NormaliseAgeFact(result.AgeAtStartOfSeries),
Sex = NormaliseSexFact(result.Sex),
Height = StrongFact(result.Height),
EyeColour = StrongFact(result.EyeColour),
EvidenceSceneIDs = result.EvidenceSceneIDs.Where(id => id > 0).Distinct().Take(20).ToList()
})
.Where(result => !string.IsNullOrWhiteSpace(result.Summary)
|| !string.IsNullOrWhiteSpace(result.DateOfBirth.Value)
|| !string.IsNullOrWhiteSpace(result.AgeAtStartOfSeries.Value)
|| !string.IsNullOrWhiteSpace(result.Sex.Value)
|| !string.IsNullOrWhiteSpace(result.Height.Value)
|| !string.IsNullOrWhiteSpace(result.EyeColour.Value))
.ToList();
private static CharacterEnrichmentFactResult NormaliseDateOfBirth(
CharacterEnrichmentFactResult dateOfBirth,
CharacterEnrichmentFactResult ageAtStartOfSeries)
{
var direct = StrongFact(dateOfBirth);
if (!string.IsNullOrWhiteSpace(direct.Value))
{
var parsed = ParseDate(direct.Value);
return parsed.HasValue
? CopyFact(direct, parsed.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))
: direct;
}
if (Clamp(ageAtStartOfSeries.Confidence) < FactMinimumConfidence || ageAtStartOfSeries.SceneID is not > 0)
{
return new CharacterEnrichmentFactResult();
}
var source = string.Join(" ", ageAtStartOfSeries.Value, ageAtStartOfSeries.Evidence);
var match = BirthdayAgeDateRegex.Match(source);
if (!match.Success
|| !int.TryParse(match.Groups["age"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var age)
|| age <= 0
|| age > 120)
{
return new CharacterEnrichmentFactResult();
}
var birthday = ParseDate(match.Groups["date"].Value);
if (!birthday.HasValue)
{
return new CharacterEnrichmentFactResult();
}
return new CharacterEnrichmentFactResult
{
Value = birthday.Value.AddYears(-age).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Confidence = Clamp(ageAtStartOfSeries.Confidence),
Evidence = Clean($"Derived from age evidence: {ageAtStartOfSeries.Evidence}", 700),
SceneID = ageAtStartOfSeries.SceneID
};
}
private static CharacterEnrichmentFactResult NormaliseAgeFact(CharacterEnrichmentFactResult fact)
{
var strong = StrongFact(fact);
if (string.IsNullOrWhiteSpace(strong.Value))
{
return strong;
}
var match = LeadingAgeRegex.Match(strong.Value);
if (!match.Success
|| !int.TryParse(match.Groups["age"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var age)
|| age <= 0
|| age > 120)
{
return new CharacterEnrichmentFactResult();
}
return CopyFact(strong, age.ToString(CultureInfo.InvariantCulture));
}
private static CharacterEnrichmentFactResult NormaliseSexFact(CharacterEnrichmentFactResult fact)
{
var strong = StrongFact(fact);
var value = strong.Value?.Trim();
if (string.IsNullOrWhiteSpace(value))
{
return new CharacterEnrichmentFactResult();
}
var normalised = value.Equals("female", StringComparison.OrdinalIgnoreCase) ? "Female"
: value.Equals("male", StringComparison.OrdinalIgnoreCase) ? "Male"
: value.Equals("other", StringComparison.OrdinalIgnoreCase) ? "Other"
: string.Empty;
return string.IsNullOrWhiteSpace(normalised)
? new CharacterEnrichmentFactResult()
: CopyFact(strong, normalised);
}
private static CharacterEnrichmentFactResult CopyFact(CharacterEnrichmentFactResult fact, string value)
=> new()
{
Value = value,
Confidence = fact.Confidence,
Evidence = fact.Evidence,
SceneID = fact.SceneID
};
private static DateTime? ParseDate(string? value)
{
var clean = Regex.Replace(value ?? string.Empty, @"\b(\d{1,2})(st|nd|rd|th)\b", "$1", RegexOptions.IgnoreCase).Trim();
return DateTime.TryParseExact(clean, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|| DateTime.TryParse(clean, CultureInfo.GetCultureInfo("en-GB"), DateTimeStyles.None, out parsed)
? parsed.Date
: null;
}
private static CharacterEnrichmentFactResult StrongFact(CharacterEnrichmentFactResult fact)
=> Clamp(fact.Confidence) >= FactMinimumConfidence && !string.IsNullOrWhiteSpace(fact.Value) && fact.SceneID is > 0
? new CharacterEnrichmentFactResult
{
Value = Clean(fact.Value, 120),
Confidence = Clamp(fact.Confidence),
Evidence = Clean(fact.Evidence, 700),
SceneID = fact.SceneID
}
: new CharacterEnrichmentFactResult();
private static string BuildPrompt(CharacterEnrichmentContext context)
{
var builder = new StringBuilder();
builder.AppendLine("You are PlotDirector's Character Enrichment engine.");
builder.AppendLine("Create concise author-facing character summaries and extract only strongly evidenced character facts.");
builder.AppendLine("Use only the supplied manuscript scenes. Do not invent, infer from behaviour alone, speculate about future events, or list every appearance.");
builder.AppendLine("Do not place ascertainable facts only in the summary. When sex, date of birth, age at start of series, height, or eye colour can be ascertained from direct wording, populate the matching structured field too.");
builder.AppendLine("For sex, return Male, Female, or Other only when direct wording, pronouns, or relationship wording strongly supports it. Otherwise return null value and confidence 0.");
builder.AppendLine("For dateOfBirth, ageAtStartOfSeries, height, and eyeColour, return a value only when direct manuscript wording strongly supports it. Otherwise return null value and confidence 0.");
builder.AppendLine("For ageAtStartOfSeries, prefer a plain integer value such as 18. Put dates or explanation in evidence, not in the value.");
builder.AppendLine("For dateOfBirth, prefer ISO yyyy-MM-dd when directly stated or safely derived from an explicit age and birthday date.");
builder.AppendLine("If a character already has a stored value, preserve it by returning null unless the manuscript contains stronger direct evidence; the save layer will still refuse to overwrite existing author data.");
builder.AppendLine();
builder.AppendLine("[BOOK]");
builder.AppendLine($"BookID: {context.BookID}");
builder.AppendLine($"Title: {context.BookTitle}");
builder.AppendLine($"Story era: {context.StoryEra}");
builder.AppendLine($"Series start date: {context.SeriesStartDate:yyyy-MM-dd}");
builder.AppendLine();
builder.AppendLine("[CHARACTERS]");
foreach (var character in context.Characters)
{
builder.AppendLine();
builder.AppendLine($"CharacterID: {character.CharacterID}");
builder.AppendLine($"Name: {character.CharacterName}");
builder.AppendLine($"Aliases: {string.Join(", ", character.Aliases)}");
builder.AppendLine($"Existing birth date: {character.BirthDate:yyyy-MM-dd}");
builder.AppendLine($"Existing age at series start: {character.AgeAtSeriesStart}");
builder.AppendLine($"Existing height: {character.Height}");
builder.AppendLine($"Existing eye colour: {character.EyeColour}");
builder.AppendLine($"Existing description: {character.DefaultDescription}");
builder.AppendLine("[SCENES]");
foreach (var scene in character.Scenes)
{
builder.AppendLine($"SceneID: {scene.SceneID}; Chapter {scene.ChapterNumber:0.##} {scene.ChapterTitle}; Scene {scene.SceneNumber:0.##} {scene.SceneTitle}");
builder.AppendLine("<text>");
builder.AppendLine(scene.SourceText);
builder.AppendLine("</text>");
}
}
return builder.ToString();
}
private static CharacterEnrichmentProgressEvent ToProgress(CharacterEnrichmentRun run, string status, int total, int processed, string stage, string message, string? error = null)
=> new()
{
CharacterEnrichmentRunID = run.CharacterEnrichmentRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
CharacterID = run.CharacterID,
Status = status,
TotalCharacters = total,
ProcessedCharacters = processed,
CurrentStage = stage,
CurrentMessage = message,
ErrorMessage = error,
UpdatedUtc = DateTime.UtcNow
};
private static string ExtractOutputText(string rawResponseText)
{
using var response = JsonDocument.Parse(rawResponseText);
foreach (var output in response.RootElement.GetProperty("output").EnumerateArray())
{
if (!output.TryGetProperty("content", out var content))
{
continue;
}
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("type", out var type)
&& string.Equals(type.GetString(), "output_text", StringComparison.OrdinalIgnoreCase)
&& item.TryGetProperty("text", out var text)
&& !string.IsNullOrWhiteSpace(text.GetString()))
{
return text.GetString()!.Trim();
}
}
}
throw new JsonException("OpenAI response did not contain output_text content.");
}
private static decimal Clamp(decimal value)
=> Math.Clamp(value, 0m, 1m);
private static string Clean(string? value, int maxLength)
{
var clean = value?.Trim();
if (string.IsNullOrWhiteSpace(clean))
{
return string.Empty;
}
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd();
}
}
public static class CharacterEnrichmentStructuredOutputSchema
{
public static readonly StoryIntelligenceResponseContract Contract = new()
{
Name = "character_enrichment",
Strict = true,
Schema = new Dictionary<string, object>
{
["type"] = "object",
["additionalProperties"] = false,
["required"] = new[] { "schemaVersion", "characters" },
["properties"] = new Dictionary<string, object>
{
["schemaVersion"] = StringSchema(),
["characters"] = ArraySchema(ObjectSchema(new Dictionary<string, object>
{
["characterID"] = IntegerSchema(),
["summary"] = StringSchema(),
["confidence"] = NumberSchema(),
["dateOfBirth"] = FactSchema(),
["ageAtStartOfSeries"] = FactSchema(),
["sex"] = FactSchema(),
["height"] = FactSchema(),
["eyeColour"] = FactSchema(),
["evidenceSceneIDs"] = ArraySchema(IntegerSchema())
}, "characterID", "summary", "confidence", "dateOfBirth", "ageAtStartOfSeries", "sex", "height", "eyeColour", "evidenceSceneIDs"))
}
}
};
private static Dictionary<string, object> FactSchema()
=> ObjectSchema(new Dictionary<string, object>
{
["value"] = NullableStringSchema(),
["confidence"] = NumberSchema(),
["evidence"] = NullableStringSchema(),
["sceneID"] = NullableIntegerSchema()
}, "value", "confidence", "evidence", "sceneID");
private static Dictionary<string, object> ObjectSchema(Dictionary<string, object> properties, params string[] required)
=> new()
{
["type"] = "object",
["additionalProperties"] = false,
["required"] = required,
["properties"] = properties
};
private static Dictionary<string, object> ArraySchema(object items)
=> new()
{
["type"] = "array",
["items"] = items
};
private static Dictionary<string, object> StringSchema()
=> new() { ["type"] = "string" };
private static Dictionary<string, object> NullableStringSchema()
=> new() { ["type"] = new[] { "string", "null" } };
private static Dictionary<string, object> IntegerSchema()
=> new() { ["type"] = "integer" };
private static Dictionary<string, object> NullableIntegerSchema()
=> new() { ["type"] = new[] { "integer", "null" } };
private static Dictionary<string, object> NumberSchema()
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };
}

View File

@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
@ -189,8 +188,6 @@ public interface IPlotService
public interface IAssetService
{
Task<EntityMergeViewModel?> GetMergeAsync(int sourceId);
Task<EntityMergeResult?> MergeAsync(EntityMergeForm form);
Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId);
Task<StoryAssetEditViewModel?> GetCreateAssetAsync(int projectId);
Task<StoryAssetEditViewModel?> GetEditAssetAsync(int storyAssetId);
@ -210,9 +207,6 @@ public interface IAssetService
public interface ILocationService
{
Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId);
Task<EntityMergeViewModel?> GetMergeAsync(int sourceId);
Task<EntityMergeResult?> MergeAsync(EntityMergeForm form);
Task<LocationListViewModel?> GetLocationsAsync(int projectId);
Task<LocationEditViewModel?> GetCreateAsync(int projectId);
Task<LocationEditViewModel?> GetEditAsync(int locationId);
@ -275,7 +269,6 @@ public interface ICharacterService
Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId);
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
Task<CharacterEnrichmentRun?> QueueCharacterReanalysisAsync(int characterId);
Task SetPreferredNameAsync(int characterId, string preferredName);
Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form);
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
@ -318,244 +311,6 @@ public interface ITimelineService
Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model);
}
public interface IStoryCalendarService
{
Task<StoryCalendarViewModel?> GetAsync(int projectId, int? bookId, DateOnly? month, DateOnly? selectedDate);
}
public sealed class StoryCalendarService(ITimelineRepository timelineRepository) : IStoryCalendarService
{
private static readonly CultureInfo UkCulture = CultureInfo.GetCultureInfo("en-GB");
public async Task<StoryCalendarViewModel?> GetAsync(int projectId, int? bookId, DateOnly? month, DateOnly? selectedDate)
{
var timeline = await timelineRepository.GetByProjectAsync(projectId, bookId, includeMetrics: false, metricTypeIds: [], includePlotLines: false);
if (timeline.Project is null)
{
return null;
}
var chaptersById = timeline.Chapters.ToDictionary(chapter => chapter.ChapterID);
var booksById = timeline.Books.ToDictionary(book => book.BookID);
var calendarScenes = BuildSceneViewModels(timeline.Scenes, chaptersById, booksById);
var datedScenes = calendarScenes
.Where(scene => scene.StoryDate.HasValue)
.OrderBy(scene => scene.StoryDate)
.ThenBy(scene => scene.TimeOfDayRank)
.ThenBy(scene => scene.StartDateTime?.TimeOfDay ?? TimeSpan.Zero)
.ThenBy(scene => scene.NarrativeOrder)
.ToList();
var unplacedScenes = calendarScenes
.Where(scene => !scene.StoryDate.HasValue)
.OrderBy(scene => scene.NarrativeOrder)
.ToList();
var earliest = datedScenes.Select(scene => scene.StoryDate!.Value).DefaultIfEmpty().Min();
var latest = datedScenes.Select(scene => scene.StoryDate!.Value).DefaultIfEmpty().Max();
var hasDatedScenes = datedScenes.Count > 0;
DateOnly? earliestDate = hasDatedScenes ? earliest : null;
DateOnly? latestDate = hasDatedScenes ? latest : null;
var selectedMonth = ResolveInitialMonth(month, earliestDate);
var selectedStoryDate = selectedDate ?? selectedMonth;
var weeks = selectedMonth.HasValue
? BuildCalendarWeeks(selectedMonth.Value, datedScenes, selectedStoryDate)
: [];
var selectedDay = selectedStoryDate.HasValue
? weeks.SelectMany(week => week.Days).FirstOrDefault(day => day.Date == selectedStoryDate.Value)
: null;
if (selectedDay is null && selectedStoryDate.HasValue)
{
selectedDay = new StoryCalendarDayViewModel
{
Date = selectedStoryDate.Value,
IsInSelectedMonth = selectedMonth.HasValue && selectedStoryDate.Value.Year == selectedMonth.Value.Year && selectedStoryDate.Value.Month == selectedMonth.Value.Month,
IsSelected = true,
Scenes = datedScenes.Where(scene => scene.StoryDate == selectedStoryDate.Value).ToList()
};
}
return new StoryCalendarViewModel
{
Project = timeline.Project,
SelectedBookID = bookId,
SelectedMonth = selectedMonth,
SelectedDate = selectedStoryDate,
EarliestStoryDate = earliestDate,
LatestStoryDate = latestDate,
PreviousMonth = selectedMonth?.AddMonths(-1),
NextMonth = selectedMonth?.AddMonths(1),
PreviousStoryMonth = selectedMonth.HasValue ? AdjacentStoryMonth(datedScenes, selectedMonth.Value, previous: true) : null,
NextStoryMonth = selectedMonth.HasValue ? AdjacentStoryMonth(datedScenes, selectedMonth.Value, previous: false) : null,
FirstStoryMonth = earliestDate.HasValue ? FirstOfMonth(earliestDate.Value) : null,
BookOptions = BuildBookOptions(timeline.Books, bookId),
MonthOptions = BuildMonthOptions(earliestDate, latestDate, selectedMonth),
Weeks = weeks,
DatedScenes = datedScenes,
UnplacedScenes = unplacedScenes,
SelectedDay = selectedDay
};
}
public static DateOnly? ResolveInitialMonth(DateOnly? requestedMonth, DateOnly? earliestStoryDate)
=> requestedMonth.HasValue
? FirstOfMonth(requestedMonth.Value)
: earliestStoryDate.HasValue ? FirstOfMonth(earliestStoryDate.Value) : null;
public static IReadOnlyList<StoryCalendarWeekViewModel> BuildCalendarWeeks(
DateOnly selectedMonth,
IReadOnlyList<StoryCalendarSceneViewModel> datedScenes,
DateOnly? selectedDate = null)
{
var firstOfMonth = FirstOfMonth(selectedMonth);
var daysBefore = ((int)firstOfMonth.DayOfWeek + 6) % 7;
var gridStart = firstOfMonth.AddDays(-daysBefore);
var lastOfMonth = firstOfMonth.AddMonths(1).AddDays(-1);
var daysAfter = (7 - (((int)lastOfMonth.DayOfWeek + 6) % 7) - 1) % 7;
var totalDays = daysBefore + DateTime.DaysInMonth(selectedMonth.Year, selectedMonth.Month) + daysAfter;
var scenesByDate = datedScenes
.Where(scene => scene.StoryDate.HasValue)
.GroupBy(scene => scene.StoryDate!.Value)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryCalendarSceneViewModel>)group
.OrderBy(scene => scene.TimeOfDayRank)
.ThenBy(scene => scene.StartDateTime?.TimeOfDay ?? TimeSpan.Zero)
.ThenBy(scene => scene.NarrativeOrder)
.ToList());
var weeks = new List<StoryCalendarWeekViewModel>();
for (var offset = 0; offset < totalDays; offset += 7)
{
var days = new List<StoryCalendarDayViewModel>();
for (var index = 0; index < 7; index++)
{
var date = gridStart.AddDays(offset + index);
days.Add(new StoryCalendarDayViewModel
{
Date = date,
IsInSelectedMonth = date.Year == selectedMonth.Year && date.Month == selectedMonth.Month,
IsSelected = selectedDate.HasValue && date == selectedDate.Value,
Scenes = scenesByDate.GetValueOrDefault(date, [])
});
}
weeks.Add(new StoryCalendarWeekViewModel { Days = days });
}
return weeks;
}
public static int TimeOfDayRank(Scene scene)
{
var label = $"{scene.RelativeTimeText} {scene.TimeModeName}".ToLowerInvariant();
if (label.Contains("dawn")) return 100;
if (label.Contains("morning")) return 200;
if (label.Contains("midday") || label.Contains("lunch")) return 300;
if (label.Contains("afternoon")) return 400;
if (label.Contains("evening")) return 500;
if (label.Contains("night") || label.Contains("midnight")) return 600;
return 700;
}
private static IReadOnlyList<StoryCalendarSceneViewModel> BuildSceneViewModels(
IReadOnlyList<Scene> scenes,
IReadOnlyDictionary<int, Chapter> chaptersById,
IReadOnlyDictionary<int, Book> booksById)
{
return scenes
.Where(scene => !scene.IsArchived)
.Select((scene, index) =>
{
chaptersById.TryGetValue(scene.ChapterID, out var chapter);
booksById.TryGetValue(chapter?.BookID ?? scene.BookID, out var book);
return new StoryCalendarSceneViewModel
{
SceneID = scene.SceneID,
BookID = book?.BookID ?? scene.BookID,
BookTitle = book?.BookDisplayTitle ?? scene.BookDisplayTitle,
BookNumber = book?.BookNumber ?? scene.BookNumber,
ChapterID = scene.ChapterID,
ChapterNumber = chapter?.ChapterNumber ?? scene.ChapterNumber,
ChapterTitle = chapter?.ChapterTitle ?? scene.ChapterTitle ?? string.Empty,
SceneNumber = scene.SceneNumber,
Title = string.IsNullOrWhiteSpace(scene.SceneTitle) ? $"Scene {scene.SceneNumber:g}" : scene.SceneTitle,
Pov = scene.POVCharacterName,
StartDateTime = scene.StartDateTime,
TimeMode = scene.TimeModeName,
TimeConfidence = scene.TimeConfidenceName,
RelativeTimeText = scene.RelativeTimeText,
Summary = scene.Summary ?? scene.StructuralSummary,
NarrativeOrder = index,
StoryDate = scene.StartDateTime.HasValue ? DateOnly.FromDateTime(scene.StartDateTime.Value) : null,
TimeOfDayRank = TimeOfDayRank(scene),
TimeLabel = scene.TimeLabel
};
})
.ToList();
}
private static IReadOnlyList<SelectListItem> BuildBookOptions(IReadOnlyList<Book> books, int? selectedBookId)
{
var items = new List<SelectListItem>
{
new("All books", string.Empty, !selectedBookId.HasValue)
};
items.AddRange(books
.OrderBy(book => book.SortOrder)
.ThenBy(book => book.BookNumber)
.Select(book => new SelectListItem($"Book {book.BookNumber}: {book.BookDisplayTitle}", book.BookID.ToString(CultureInfo.InvariantCulture), book.BookID == selectedBookId)));
return items;
}
private static IReadOnlyList<SelectListItem> BuildMonthOptions(DateOnly? earliest, DateOnly? latest, DateOnly? selectedMonth)
{
if (!earliest.HasValue || !latest.HasValue)
{
return [];
}
var options = new List<SelectListItem>();
var firstMonth = FirstOfMonth(earliest.Value);
var lastMonth = FirstOfMonth(latest.Value);
var normalizedSelectedMonth = selectedMonth.HasValue ? FirstOfMonth(selectedMonth.Value) : (DateOnly?)null;
if (normalizedSelectedMonth.HasValue && normalizedSelectedMonth.Value < firstMonth)
{
options.Add(BuildMonthOption(normalizedSelectedMonth.Value, selected: true));
}
for (var month = firstMonth; month <= lastMonth; month = month.AddMonths(1))
{
options.Add(BuildMonthOption(month, normalizedSelectedMonth == month));
}
if (normalizedSelectedMonth.HasValue && normalizedSelectedMonth.Value > lastMonth)
{
options.Add(BuildMonthOption(normalizedSelectedMonth.Value, selected: true));
}
return options;
}
private static SelectListItem BuildMonthOption(DateOnly month, bool selected)
=> new(month.ToString("MMMM yyyy", UkCulture), month.ToString("yyyy-MM", CultureInfo.InvariantCulture), selected);
private static DateOnly? AdjacentStoryMonth(IReadOnlyList<StoryCalendarSceneViewModel> datedScenes, DateOnly selectedMonth, bool previous)
{
var months = datedScenes
.Where(scene => scene.StoryDate.HasValue)
.Select(scene => FirstOfMonth(scene.StoryDate!.Value))
.Distinct()
.OrderBy(date => date)
.ToList();
var adjacent = previous
? months.Where(date => date < selectedMonth).Cast<DateOnly?>().LastOrDefault()
: months.Where(date => date > selectedMonth).Cast<DateOnly?>().FirstOrDefault();
return adjacent;
}
private static DateOnly FirstOfMonth(DateOnly date)
=> new(date.Year, date.Month, 1);
}
public interface ISceneMetricTypeService
{
Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId);
@ -1420,7 +1175,6 @@ public sealed class BookService(
ISubscriptionService subscriptions,
IProjectActivityService activity,
IStoryIntelligencePipelineStateService storyIntelligencePipeline,
IStoryIntelligenceResultRepository storyIntelligenceRuns,
ICurrentUserService currentUser) : IBookService
{
public async Task<BookDetailViewModel?> GetDetailAsync(int bookId)
@ -1440,9 +1194,6 @@ public sealed class BookService(
var pipeline = currentUser.UserId is int pipelineUserId
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
: null;
var runRows = currentUser.UserId is int runUserId
? await storyIntelligenceRuns.ListRunsByBookForUserAsync(bookId, runUserId)
: [];
var hasCanonicalSceneSources = (await builds.ListSceneManuscriptSourcesByBookAsync(bookId)).Count > 0;
return new BookDetailViewModel
@ -1454,7 +1205,6 @@ public sealed class BookService(
: null,
Chapters = await chapters.ListByBookAsync(bookId),
StoryIntelligencePipeline = pipeline,
StoryIntelligenceRuns = runRows,
StoryIntelligenceAction = BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)
};
}
@ -3368,7 +3118,7 @@ public sealed class TimelineService(
}
}
public sealed partial class StoryStateService(
public sealed class StoryStateService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
@ -3767,18 +3517,15 @@ public sealed partial class StoryStateService(
BuildSceneContexts(timeline));
}
public static IEnumerable<Scene> OrderScenes(TimelineData timeline)
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
{
var chaptersById = timeline.Chapters.ToDictionary(x => x.ChapterID);
var bookSortOrders = timeline.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
var booksById = timeline.Books.ToDictionary(x => x.BookID);
var chapterSortOrders = timeline.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
return timeline.Scenes
.OrderBy(scene => bookSortOrders.GetValueOrDefault(chaptersById.GetValueOrDefault(scene.ChapterID)?.BookID ?? 0))
.ThenBy(scene => booksById.GetValueOrDefault(chaptersById.GetValueOrDefault(scene.ChapterID)?.BookID ?? 0)?.BookNumber)
.ThenBy(scene => chapterSortOrders.GetValueOrDefault(scene.ChapterID))
.ThenBy(scene => chaptersById.GetValueOrDefault(scene.ChapterID)?.ChapterNumber)
.ThenBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID);
@ -3893,13 +3640,6 @@ public sealed partial class StoryStateService(
{
var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
var locationsByAsset = assetLocations.Where(x => allowedSceneIds.Contains(x.SceneID)).GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(y => sceneIndexes[y.SceneID]).ThenBy(y => y.UpdatedDate).ThenBy(y => y.SceneAssetLocationID).Last());
var custodyByAsset = assetCustody.Where(x => allowedSceneIds.Contains(x.SceneID)).GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(y => sceneIndexes[y.SceneID]).ThenBy(y => y.UpdatedDate).ThenBy(y => y.AssetCustodyEventID).Last());
var eventsByAsset = assetEvents.Where(x => allowedSceneIds.Contains(x.SceneID)).GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(y => sceneIndexes[y.SceneID]).ThenBy(y => y.UpdatedDate).ThenBy(y => y.AssetEventID).ToList());
return projectAssets
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
@ -3912,13 +3652,7 @@ public sealed partial class StoryStateService(
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
var location = locationsByAsset.GetValueOrDefault(asset.StoryAssetID);
var custody = custodyByAsset.GetValueOrDefault(asset.StoryAssetID);
var events = eventsByAsset.GetValueOrDefault(asset.StoryAssetID, []);
var lastEvent = events.LastOrDefault();
var lastSceneId = new int?[] { latest?.SceneID, lastEvent?.SceneID, custody?.SceneID, location?.SceneID }
.Where(x => x.HasValue).OrderBy(x => sceneIndexes[x!.Value]).LastOrDefault();
if (lastSceneId is null)
if (latest is null)
{
return new AssetStoryStateViewModel
{
@ -3931,16 +3665,11 @@ public sealed partial class StoryStateService(
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName,
LocationID = location?.LocationID,
Location = location?.LocationPath ?? "Unknown",
Holder = custody?.CustodianSummary ?? "Unknown",
CustodyEventID = custody?.AssetCustodyEventID,
Status = events.LastOrDefault(x => !string.IsNullOrWhiteSpace(x.ToStateName))?.ToStateName ?? "Unknown",
LatestEvent = custody?.SceneID == lastSceneId ? custody.CustodyEventTypeName : lastEvent?.SceneID == lastSceneId ? lastEvent.EventTitle : "Location recorded",
CurrentHolderOrLocation = latest?.Label ?? "Unknown",
State = lastSceneId == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = lastSceneId,
LastConfirmed = scenesById.TryGetValue(lastSceneId.Value, out var scene)
LocationID = latest.LocationID,
CurrentHolderOrLocation = latest.Label,
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
? SceneLabel(scene.SceneNumber, scene.SceneTitle)
: "Unknown"
};
@ -4154,16 +3883,6 @@ public sealed partial class StoryStateService(
IReadOnlyList<Character> projectCharacters,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
var items = new List<CharacterKnowledgeItem>();
foreach (var character in projectCharacters.Where(x => !enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
items.AddRange(await characters.ListKnowledgeByCharacterAsync(character.CharacterID));
return BuildKnowledgeRegressionWarnings(projectCharacters, enabledCharacterIds, context, items);
}
private static List<ContinuityWarningViewModel> BuildKnowledgeRegressionWarnings(
IReadOnlyList<Character> projectCharacters, IReadOnlySet<int> enabledCharacterIds,
RangeContext context, IReadOnlyList<CharacterKnowledgeItem> items)
{
var warnings = new List<ContinuityWarningViewModel>();
var charactersToCheck = projectCharacters
@ -4172,7 +3891,7 @@ public sealed partial class StoryStateService(
foreach (var character in charactersToCheck)
{
var knowledgeEvents = items.Where(x => x.CharacterID == character.CharacterID)
var knowledgeEvents = (await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
.Where(x => context.SceneIndexes.ContainsKey(x.SceneID))
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue))
.ThenBy(x => x.CreatedDate)
@ -5977,32 +5696,13 @@ public sealed class RelationshipMapService(IProjectRepository projects, ICharact
};
}
public static IReadOnlyList<RelationshipMapRelationshipViewModel> BuildAtScene(
RelationshipMapData data, IReadOnlyDictionary<int, int> sceneOrder, int sceneId)
{
if (!sceneOrder.TryGetValue(sceneId, out var maximum)) return [];
var events = data.RelationshipEvents.GroupBy(x => x.CharacterRelationshipID)
.ToDictionary(x => x.Key, x => (IReadOnlyList<RelationshipEvent>)x.ToList());
var chapters = data.Chapters.ToDictionary(x => x.ChapterID);
var firstSceneByBook = data.Scenes.Where(x => sceneOrder.ContainsKey(x.SceneID) && chapters.ContainsKey(x.ChapterID))
.GroupBy(x => chapters[x.ChapterID].BookID).ToDictionary(x => x.Key, x => x.Min(y => sceneOrder[y.SceneID]));
return data.Relationships
.Where(x => !x.IsArchived
&& (!x.StartSceneID.HasValue || SceneIndex(sceneOrder, x.StartSceneID.Value) <= maximum)
&& (!x.EndSceneID.HasValue || SceneIndex(sceneOrder, x.EndSceneID.Value) > maximum)
&& (!x.InitialBookID.HasValue || firstSceneByBook.GetValueOrDefault(x.InitialBookID.Value, int.MaxValue) <= maximum))
.Where(x => x.IsInitialRelationship || x.StartSceneID.HasValue || events.ContainsKey(x.CharacterRelationshipID))
.Select(x => BuildRelationship(x, events.GetValueOrDefault(x.CharacterRelationshipID, []), sceneOrder, maximum, x.StartSceneID.HasValue))
.OfType<RelationshipMapRelationshipViewModel>().ToList();
}
private static RelationshipMapRelationshipViewModel? BuildRelationship(CharacterRelationship relationship, IReadOnlyList<RelationshipEvent> events, IReadOnlyDictionary<int, int> sceneOrder, int maxSceneIndex, bool hasDatedInitialState = false)
private static RelationshipMapRelationshipViewModel? BuildRelationship(CharacterRelationship relationship, IReadOnlyList<RelationshipEvent> events, IReadOnlyDictionary<int, int> sceneOrder, int maxSceneIndex)
{
var visibleEvents = maxSceneIndex < 0
? []
: events.Where(x => SceneIndex(sceneOrder, x.SceneID) <= maxSceneIndex).ToList();
var hasInitialState = hasDatedInitialState || relationship.IsInitialRelationship || !events.Any();
var hasInitialState = relationship.IsInitialRelationship || !events.Any();
if (!hasInitialState && visibleEvents.Count == 0)
{
return null;
@ -6057,7 +5757,13 @@ public sealed class RelationshipMapService(IProjectRepository projects, ICharact
private static IReadOnlyDictionary<int, int> BuildSceneOrder(RelationshipMapData data)
{
return StoryStateService.OrderScenes(new TimelineData { Books = data.Books, Chapters = data.Chapters, Scenes = data.Scenes })
var bookSort = data.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
var chapterSort = data.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
return data.Scenes
.OrderBy(x => bookSort.TryGetValue(data.Chapters.FirstOrDefault(c => c.ChapterID == x.ChapterID)?.BookID ?? 0, out var b) ? b : 0)
.ThenBy(x => chapterSort.TryGetValue(x.ChapterID, out var c) ? c : 0)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.SceneNumber)
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
}
@ -7915,40 +7621,6 @@ public sealed class AssetService(
IVisualIdentityImageService visualIdentityImages,
ICurrentUserService currentUser) : IAssetService
{
public async Task<EntityMergeViewModel?> GetMergeAsync(int sourceId)
{
var source = await assets.GetAssetAsync(sourceId);
if (source is null || source.IsArchived) return null;
var project = await projects.GetAsync(source.ProjectID);
if (project is null) return null;
return new EntityMergeViewModel
{
Project = project,
SourceName = source.AssetName,
Form = new EntityMergeForm { SourceID = sourceId },
TargetOptions = (await assets.ListAssetsAsync(source.ProjectID))
.Where(x => x.StoryAssetID != sourceId && !x.IsArchived)
.OrderBy(x => x.AssetName, StringComparer.OrdinalIgnoreCase)
.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString())).ToList()
};
}
public async Task<EntityMergeResult?> MergeAsync(EntityMergeForm form)
{
if (!form.Confirm) throw new InvalidOperationException("Confirm the merge before continuing.");
if (form.SourceID <= 0 || form.TargetID <= 0 || form.SourceID == form.TargetID)
throw new InvalidOperationException("Choose two different assets to merge.");
var source = await GetMergeAsync(form.SourceID);
if (source is null) return null;
if (!source.TargetOptions.Any(x => x.Value == form.TargetID.ToString()))
throw new InvalidOperationException("Choose an active asset from the same project.");
var result = await assets.MergeAsync(form.SourceID, form.TargetID, currentUser.UserId);
if (result is not null)
await activity.RecordAsync(result.ProjectID, "Merged", "Asset", result.TargetID, result.TargetName,
$"{result.SourceName} was merged into {result.TargetName}.");
return result;
}
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
@ -8258,43 +7930,6 @@ public sealed class LocationService(
ICurrentUserService currentUser,
ILogger<LocationService> logger) : ILocationService
{
public Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId)
=> locations.ReparentAsync(projectId, locationId, parentLocationId, expectedParentLocationId, currentUser.UserId);
public async Task<EntityMergeViewModel?> GetMergeAsync(int sourceId)
{
var source = await locations.GetAsync(sourceId);
if (source is null || source.IsArchived) return null;
var project = await projects.GetAsync(source.ProjectID);
if (project is null) return null;
return new EntityMergeViewModel
{
Project = project,
SourceName = source.LocationName,
Form = new EntityMergeForm { SourceID = sourceId },
TargetOptions = (await locations.ListByProjectAsync(source.ProjectID))
.Where(x => x.LocationID != sourceId && !x.IsArchived)
.OrderBy(x => x.LocationName, StringComparer.OrdinalIgnoreCase)
.Select(x => new SelectListItem(x.LocationName, x.LocationID.ToString())).ToList()
};
}
public async Task<EntityMergeResult?> MergeAsync(EntityMergeForm form)
{
if (!form.Confirm) throw new InvalidOperationException("Confirm the merge before continuing.");
if (form.SourceID <= 0 || form.TargetID <= 0 || form.SourceID == form.TargetID)
throw new InvalidOperationException("Choose two different locations to merge.");
var source = await GetMergeAsync(form.SourceID);
if (source is null) return null;
if (!source.TargetOptions.Any(x => x.Value == form.TargetID.ToString()))
throw new InvalidOperationException("Choose an active location from the same project.");
var result = await locations.MergeAsync(form.SourceID, form.TargetID, currentUser.UserId);
if (result is not null)
await activity.RecordAsync(result.ProjectID, "Merged", "Location", result.TargetID, result.TargetName,
$"{result.SourceName} was merged into {result.TargetName}.");
return result;
}
public async Task<LocationListViewModel?> GetLocationsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
@ -9612,9 +9247,7 @@ public sealed class CharacterService(
ISubscriptionService subscriptions,
IProjectActivityService activity,
ICurrentUserService currentUser,
IVisualIdentityImageService visualIdentityImages,
ICharacterEnrichmentService characterEnrichment,
ITimelineRepository timelineRepository) : ICharacterService
IVisualIdentityImageService visualIdentityImages) : ICharacterService
{
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
{
@ -9683,9 +9316,6 @@ public sealed class CharacterService(
var projectCharacters = await characters.ListCharactersAsync(project.ProjectID);
var relationshipLookups = await characters.GetLookupsAsync();
var projectBooks = await books.ListByProjectAsync(project.ProjectID);
var characterAnalysis = currentUser.UserId is int userId
? await characterEnrichment.GetCharacterStatusAsync(character.CharacterID, userId) ?? new CharacterEnrichmentCharacterStatus { CharacterID = character.CharacterID }
: new CharacterEnrichmentCharacterStatus { CharacterID = character.CharacterID };
var relationshipContainers = await characters.ListRelationshipsByCharacterAsync(character.CharacterID);
var initialRelationships = await characters.ListInitialRelationshipsByCharacterAsync(character.CharacterID);
@ -9694,14 +9324,6 @@ public sealed class CharacterService(
.GroupBy(x => x.CharacterRelationshipID)
.ToDictionary(x => x.Key, x => (IReadOnlyList<RelationshipEvent>)x.ToList());
var characterTimeline = await characters.GetTimelineAsync(project.ProjectID, null);
var timeline = await timelineRepository.GetByProjectAsync(project.ProjectID, null, false, [], false);
var orderedScenes = StoryStateService.OrderScenes(timeline).ToList();
var sceneIndexes = orderedScenes.Select((scene, index) => (scene.SceneID, index)).ToDictionary(x => x.SceneID, x => x.index);
var appearances = characterTimeline.Appearances.Where(x => x.CharacterID == character.CharacterID && sceneIndexes.ContainsKey(x.SceneID))
.OrderBy(x => sceneIndexes[x.SceneID]).ToList();
var profileAge = CharacterAgeDisplayService.GetProfileAge(character, orderedScenes, appearances);
return new CharacterDetailViewModel
{
Project = project,
@ -9709,10 +9331,8 @@ public sealed class CharacterService(
Aliases = await characters.ListAliasesAsync(character.CharacterID),
ProjectCharacters = projectCharacters,
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
DisplayAge = profileAge.DisplayText,
AgeContext = profileAge.Context,
AgeReferenceScene = profileAge.ReferenceScene,
Appearances = appearances,
DisplayAge = DisplayAge(character),
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID),
InitialRelationships = initialRelationships,
@ -9747,21 +9367,10 @@ public sealed class CharacterService(
CharacterOptions = projectCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString())).ToList(),
RelationshipTypeOptions = ToRelationshipTypeSelectList(relationshipLookups.RelationshipTypes),
RelationshipStateOptions = relationshipLookups.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString())).ToList(),
CharacterAnalysis = characterAnalysis
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString())).ToList()
};
}
public async Task<CharacterEnrichmentRun?> QueueCharacterReanalysisAsync(int characterId)
{
if (currentUser.UserId is not int userId)
{
return null;
}
return await characterEnrichment.QueueForCharacterAsync(characterId, userId);
}
public async Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId)
{
var source = await characters.GetCharacterAsync(sourceCharacterId);
@ -9833,9 +9442,8 @@ public sealed class CharacterService(
ShortName = existing?.ShortName,
SexValueID = sex.SexValueID,
Sex = sex.SexName,
BirthDate = model.BirthDate?.Date,
BirthDate = model.BirthDate,
AgeAtSeriesStart = model.AgeAtSeriesStart,
AgeReferenceSceneID = existing?.AgeReferenceSceneID,
Height = model.Height,
EyeColour = model.EyeColour,
CharacterImportance = model.CharacterImportance,
@ -10293,5 +9901,9 @@ public sealed class CharacterService(
.ToList();
}
private async Task<IReadOnlyList<SceneCharacter>> GetAppearancesByCharacterAsync(int characterId)
{
var timeline = await characters.GetTimelineAsync((await characters.GetCharacterAsync(characterId))?.ProjectID ?? 0, null);
return timeline.Appearances.Where(x => x.CharacterID == characterId).ToList();
}
}

View File

@ -1,151 +0,0 @@
using System.Text.RegularExpressions;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public sealed record LocationSignificanceEvidence(int SceneID, int ChapterID, int BookID, string SceneLabel,
bool IsSetting, int EventScore, int ContextScore, IReadOnlyList<string> Reasons,
IReadOnlyList<string> Characters, IReadOnlyList<string> PlotLines);
public sealed class LocationSignificanceAssessment
{
public int Version { get; init; } = 1;
public int Score { get; init; }
public string Classification { get; init; } = "Incidental Location";
public IReadOnlyList<string> Reasons { get; init; } = [];
public IReadOnlyList<string> Characters { get; init; } = [];
public IReadOnlyList<string> PlotLines { get; init; } = [];
public IReadOnlyList<LocationSignificanceEvidence> KeyScenes { get; init; } = [];
public bool IsSignificant => Score >= 40 || Classification == "Primary Setting";
}
public static class LocationSignificanceService
{
// Severe events are distinct from exposition verbs such as "reveals" or "discovers".
private static readonly Regex Consequence = new(@"\b(murder(?:ed)?|kill(?:ed|ing)?|death|dies?|dead body|abduct(?:ed|ion)?|kidnap(?:ped|ping)?|betray(?:s|ed|al)?|rescu(?:e|ed)|climax|turning point|major decision|arrest(?:ed)?|destroy(?:s|ed)|burns? down)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex Development = new(@"\b(reveal(?:s|ed)?|revelation|discover(?:s|ed|y)?|confront(?:ation|s|ed)?|confess(?:ion|es|ed)?)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex Stakes = new(@"\b(secret|identity|truth|evidence|abuse|missing|cover.up|body|critical|decisive)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex Negation = new(@"\b(no|not|never|imagined|hypothetical|might|could|would|rumou?r|remember(?:ed|s)?)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static bool Consequential(string? text) => !string.IsNullOrWhiteSpace(text) && !Negation.IsMatch(text)
&& (Consequence.IsMatch(text) || Development.IsMatch(text) && Stakes.IsMatch(text));
private static bool High(string? text) => text?.Trim().ToLowerInvariant() is "high" or "major" or "critical" or "central";
public static LocationSignificanceEvidence Observe(Scene scene, SceneIntelligenceScene parsed,
string sceneLabel, bool setting, bool present, bool mentionedOnly, string? localEvidence)
{
var reasons = new List<string>();
// Mention-only sites need their own evidence; they cannot inherit the setting's plot significance.
var eventScore = Consequential(localEvidence) ? 50 : 0;
if (eventScore > 0) reasons.Add($"Consequential location evidence: {localEvidence}");
var context = 0;
var people = new List<string>();
var plots = new List<string>();
if (setting && present && !mentionedOnly)
{
context += 12;
reasons.Add("Scene setting");
var arcs = (parsed.NarrativeArcs ?? []).Where(x => !string.IsNullOrWhiteSpace(x.Title)).ToList();
var importantArc = arcs.Any(x => High(x.Significance));
plots.AddRange(arcs.Select(x => x.Title!));
var purpose = parsed.ScenePurpose?.ObservedFunction;
if (Consequential(purpose) || importantArc && !string.IsNullOrWhiteSpace(purpose)
&& Development.IsMatch(purpose) && !Negation.IsMatch(purpose))
{
eventScore = Math.Max(eventScore, Consequential(purpose) ? 50 : 40);
reasons.Add($"Consequential scene purpose: {purpose}");
}
if (arcs.Count > 0)
{
context += importantArc ? 6 : 4;
// A major plot development is substantive evidence even without event vocabulary.
if (importantArc) eventScore = Math.Max(eventScore, 25);
reasons.Add(importantArc ? "Major plot / thread development at this setting" : "Plot / thread development at this setting");
}
var metrics = (parsed.Metrics ?? []).Where(x => Regex.IsMatch(x.Key, "tension|emotion|action|darkness|mystery", RegexOptions.IgnoreCase))
.Select(x => x.Value.Score ?? 0).Concat((parsed.MetricValues ?? []).Where(x => Regex.IsMatch(x.Key ?? "", "tension|emotion|action|darkness|mystery", RegexOptions.IgnoreCase)).Select(x => x.Score ?? 0));
if (metrics.DefaultIfEmpty().Max() >= 8)
{
context += 10;
reasons.Add("High scene tension / emotional weight / action / mystery");
}
var knowledge = (parsed.KnowledgeChanges ?? []).Where(x => !string.IsNullOrWhiteSpace(x.KnowledgeItem)).ToList();
if (knowledge.Count > 0 || (parsed.QuestionsAnswered ?? []).Any())
{
var consequentialKnowledge = knowledge.Any(x => Consequential(x.KnowledgeItem));
context += consequentialKnowledge ? 12 : 3;
reasons.Add(consequentialKnowledge ? "Consequential knowledge change at this setting" : "Knowledge change / answered question at this setting");
}
if ((parsed.Relationships ?? []).Any(x => Consequential(x.RelationshipSignal) || Consequential(x.Evidence)))
{
context += 10;
reasons.Add("Consequential relationship event at this setting");
}
people.AddRange((parsed.Characters ?? []).Where(x => x.MentionedOnly != true && !string.IsNullOrWhiteSpace(x.Name)).Select(x => x.Name!));
if (!string.IsNullOrWhiteSpace(parsed.PointOfView?.CharacterName))
{
people.Add(parsed.PointOfView.CharacterName);
context += 4;
}
}
// Correlated signals from one scene cannot swamp the event signal or flatten every score to 100.
return new(scene.SceneID, scene.ChapterID, scene.BookID, sceneLabel, setting && present && !mentionedOnly,
eventScore, Math.Min(36, context), reasons,
people.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), plots.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
}
public static LocationSignificanceAssessment Assess(string category, IEnumerable<LocationSignificanceEvidence> source)
{
var scenes = source.GroupBy(x => x.SceneID).Select(x => x.OrderByDescending(y => y.EventScore + y.ContextScore).First()).ToList();
var peak = scenes.OrderByDescending(x => x.EventScore + x.ContextScore).FirstOrDefault();
var chapters = scenes.Select(x => x.ChapterID).Distinct().Count();
var recurrence = Math.Min(8, Math.Max(0, chapters - 1) * 2);
var named = category is "Named location" ? 4 : 0;
var score = Math.Min(100, (peak?.EventScore ?? 0) + (peak?.ContextScore ?? 0) + recurrence + named);
var primary = scenes.Count(x => x.IsSetting) >= 6 && scenes.Where(x => x.IsSetting).Select(x => x.ChapterID).Distinct().Count() >= 3 && score >= 40;
var classification = primary ? "Primary Setting" : score >= 40 ? "Significant Location" : scenes.Count >= 3 ? "Recurring Location" : "Incidental Location";
var reasons = (peak?.Reasons ?? []).ToList();
if (recurrence > 0) reasons.Add($"Across {chapters} chapters (recurrence contributes {recurrence}/8)");
return new() { Score = score, Classification = classification, Reasons = reasons,
Characters = scenes.SelectMany(x => x.Characters).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList(),
PlotLines = scenes.SelectMany(x => x.PlotLines).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList(),
KeyScenes = scenes.OrderByDescending(x => x.EventScore + x.ContextScore).ThenBy(x => x.SceneID).Take(3).ToList() };
}
public static IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> OrderHierarchy(
IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> candidates, string scope)
{
var byKey = candidates.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
var included = candidates.Where(x => scope == "all" || (scope == "uncertain" ? x.IsPending && x.NeedsReview : x.Significance?.IsSignificant == true)).Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var key in included.ToList())
{
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var current = byKey[key];
while (current.ProposedParentCandidateKey is string parent && byKey.TryGetValue(parent, out current!) && visited.Add(parent)) included.Add(parent);
}
var children = candidates.ToLookup(x => x.ProposedParentCandidateKey ?? "", StringComparer.OrdinalIgnoreCase);
int Rank(StoryIntelligenceLocationReviewCandidateViewModel item, HashSet<string> path)
{
if (!path.Add(item.Key)) return 0;
var result = Math.Max(item.Significance?.Score ?? 0, children[item.Key].Select(x => Rank(x, path)).DefaultIfEmpty().Max());
path.Remove(item.Key); return result;
}
var ranks = candidates.ToDictionary(x => x.Key, x => Rank(x, new(StringComparer.OrdinalIgnoreCase)), StringComparer.OrdinalIgnoreCase);
var output = new List<StoryIntelligenceLocationReviewCandidateViewModel>(); var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Visit(StoryIntelligenceLocationReviewCandidateViewModel item, int depth)
{
if (!included.Contains(item.Key) || !emitted.Add(item.Key)) return;
item.HierarchyDepth = depth; output.Add(item);
foreach (var child in children[item.Key].OrderByDescending(x => ranks[x.Key]).ThenBy(x => x.LocationName, StringComparer.OrdinalIgnoreCase)) Visit(child, depth + 1);
}
// An already canonical parent may have no candidate row. Its siblings still form one group.
var roots = candidates.Where(x => x.ProposedParentCandidateKey is null || !byKey.ContainsKey(x.ProposedParentCandidateKey));
var rootGroups = roots.GroupBy(x => x.ProposedParentLocationID.HasValue ? $"canonical:{x.ProposedParentLocationID}"
: x.ProposedParentCandidateKey is not null ? $"parent:{x.ProposedParentCandidateKey}" : $"root:{x.Key}", StringComparer.OrdinalIgnoreCase);
foreach (var group in rootGroups.OrderByDescending(g => g.Max(x => ranks[x.Key]))
.ThenBy(g => g.First().ProposedParentLocationName ?? g.First().LocationName, StringComparer.OrdinalIgnoreCase))
foreach (var root in group.OrderByDescending(x => ranks[x.Key]).ThenBy(x => x.LocationName, StringComparer.OrdinalIgnoreCase))
Visit(root, root.ProposedParentLocationID.HasValue ? 1 : 0);
foreach (var remaining in candidates.OrderByDescending(x => ranks[x.Key]).ThenBy(x => x.LocationName)) Visit(remaining, 0);
return output;
}
}

View File

@ -47,7 +47,6 @@ public sealed class OnboardingService(
ICharacterRepository characters,
IChapterRepository chapters,
ISceneRepository scenes,
ILookupRepository lookups,
IOnboardingBuildRepository builds,
IManuscriptStructureAnalysisService structureAnalysis,
IManuscriptTextStatisticsService manuscriptTextStatistics,
@ -351,7 +350,6 @@ public sealed class OnboardingService(
await SaveStructuralChapterSourcesAsync(preview, existingBuild, recoveredScenes);
await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild);
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
await ApplyStructuralChronologyAsync(recoveredScenes, existingBuild);
await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild);
}
else
@ -439,7 +437,6 @@ public sealed class OnboardingService(
await SaveStructuralChapterSourcesAsync(preview, result, structuralScenes);
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
await ApplyStructuralChronologyAsync(structuralScenes, result);
await ApplyStructuralPovAsync(preview, structuralScenes, result);
var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
var locationReview = await coreImportBasicLocations.EnsurePreparedAsync(
@ -550,168 +547,6 @@ public sealed class OnboardingService(
}
}
private async Task ApplyStructuralChronologyAsync(
IReadOnlyList<ManuscriptStructureSceneBoundary> structuralScenes,
OnboardingManuscriptBuildResult result)
{
if (structuralScenes.Count == 0 || result.SceneMappings.Count == 0)
{
return;
}
var lookupData = await lookups.GetAllAsync();
var timeModeIds = lookupData.TimeModes.ToDictionary(item => item.TimeModeName, item => item.TimeModeID, StringComparer.OrdinalIgnoreCase);
var timeConfidenceIds = lookupData.TimeConfidences.ToDictionary(item => item.TimeConfidenceName, item => item.TimeConfidenceID, StringComparer.OrdinalIgnoreCase);
var sceneMap = result.SceneMappings.ToDictionary(mapping => mapping.TemporarySceneKey, StringComparer.Ordinal);
foreach (var chapterScenes in structuralScenes
.GroupBy(scene => scene.TemporaryChapterKey)
.OrderBy(group => group.Min(scene => scene.WordStartPosition)))
{
var temporalContext = StoryIntelligenceTemporalResolver.CreateChapterContext(
chapterScenes.FirstOrDefault()?.SourceText,
chapterScenes.FirstOrDefault()?.StructuralSummary,
chapterScenes.FirstOrDefault()?.StructuralSetting);
foreach (var structuralScene in chapterScenes.OrderBy(scene => scene.SceneNumberWithinChapter))
{
if (!sceneMap.TryGetValue(structuralScene.TemporarySceneKey, out var mappedScene))
{
continue;
}
var resolved = StoryIntelligenceTemporalResolver.Resolve(BuildStructuralTemporalScene(structuralScene), temporalContext);
temporalContext = resolved.NextContext;
if (string.Equals(resolved.TimeModeName, "Unknown", StringComparison.OrdinalIgnoreCase)
|| (!resolved.StartDateTime.HasValue && string.IsNullOrWhiteSpace(resolved.RelativeTimeText)))
{
continue;
}
var canonicalScene = await scenes.GetAsync(mappedScene.SceneID);
if (canonicalScene is null || HasExistingChronology(canonicalScene))
{
continue;
}
if (!timeModeIds.TryGetValue(resolved.TimeModeName, out var timeModeId)
|| !timeConfidenceIds.TryGetValue(resolved.TimeConfidenceName, out var timeConfidenceId))
{
continue;
}
canonicalScene.TimeModeID = timeModeId;
canonicalScene.TimeModeName = resolved.TimeModeName;
canonicalScene.StartDateTime = resolved.StartDateTime;
canonicalScene.EndDateTime = resolved.EndDateTime;
canonicalScene.DurationAmount = resolved.DurationAmount;
canonicalScene.DurationUnitID = resolved.DurationUnitID;
canonicalScene.RelativeTimeText = resolved.RelativeTimeText;
canonicalScene.TimeConfidenceID = timeConfidenceId;
canonicalScene.TimeConfidenceName = resolved.TimeConfidenceName;
await scenes.SaveAsync(canonicalScene);
}
}
}
private static SceneIntelligenceScene? BuildStructuralTemporalScene(ManuscriptStructureSceneBoundary structuralScene)
{
var evidence = ExtractStructuralTemporalEvidence(structuralScene.SourceText)
?? ExtractStructuralTemporalEvidence(structuralScene.StructuralSetting)
?? ExtractStructuralTemporalEvidence(structuralScene.StructuralSummary);
var partOfDay = FirstTemporalPartOfDay(structuralScene.StructuralSetting)
?? FirstTemporalPartOfDay(structuralScene.SourceText)
?? FirstTemporalPartOfDay(structuralScene.StructuralSummary);
if (string.IsNullOrWhiteSpace(evidence) && string.IsNullOrWhiteSpace(partOfDay))
{
return null;
}
return new SceneIntelligenceScene
{
Setting = new SceneIntelligenceSetting
{
DateOrTimeReference = evidence,
TimeOfDay = partOfDay,
Confidence = string.IsNullOrWhiteSpace(evidence) ? 0.65m : 0.9m
},
TemporalAnalysis = new SceneIntelligenceTemporalAnalysis
{
AbsoluteDate = evidence,
PartOfDay = partOfDay,
Evidence = evidence ?? partOfDay,
Confidence = string.IsNullOrWhiteSpace(evidence) ? 0.65m : 0.9m
},
TimelineClues = string.IsNullOrWhiteSpace(evidence)
? []
: [new SceneIntelligenceTimelineClue { Clue = evidence, AbsoluteDate = evidence, Evidence = evidence, Confidence = 0.9m }]
};
}
private static string? ExtractStructuralTemporalEvidence(string? value)
{
var text = Clean(value);
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
var explicitDate = Regex.Match(
text,
@"\b(?:\d{1,2}(?:st|nd|rd|th)?\s+(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{1,2}(?:st|nd|rd|th)?),?\s+\d{4}\b",
RegexOptions.IgnoreCase);
if (explicitDate.Success)
{
return explicitDate.Value;
}
var yearlessDate = Regex.Match(
text,
@"\b(?:\d{1,2}(?:st|nd|rd|th)?\s+(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{1,2}(?:st|nd|rd|th)?)\b",
RegexOptions.IgnoreCase);
if (yearlessDate.Success)
{
return yearlessDate.Value;
}
var relative = Regex.Match(
text,
@"\b(?:the\s+)?(?:same|following|next)\s+(?:morning|afternoon|evening|night|day|week|month|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b|\b(?:\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+days?\s+later\b|\bafter midnight\b|\bbefore dawn\b",
RegexOptions.IgnoreCase);
return relative.Success ? relative.Value : null;
}
private static string? FirstTemporalPartOfDay(string? value)
{
var text = Clean(value);
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
foreach (var part in new[] { "morning", "afternoon", "evening", "night", "dawn", "dusk", "midday", "midnight" })
{
if (text.Contains(part, StringComparison.OrdinalIgnoreCase))
{
return part;
}
}
return null;
}
private static bool HasExistingChronology(Scene scene)
=> scene.StartDateTime.HasValue
|| scene.EndDateTime.HasValue
|| scene.DurationAmount.HasValue
|| scene.DurationUnitID.HasValue
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText)
|| (!string.IsNullOrWhiteSpace(scene.TimeModeName)
&& !string.Equals(scene.TimeModeName, "Unknown", StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrWhiteSpace(scene.TimeConfidenceName)
&& !string.Equals(scene.TimeConfidenceName, "Unknown", StringComparison.OrdinalIgnoreCase));
private async Task ApplyStructuralPovAsync(
ManuscriptScanPreview preview,
IReadOnlyList<ManuscriptStructureSceneBoundary> structuralScenes,

View File

@ -14,7 +14,7 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25, string scope = "significant");
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
@ -52,7 +52,6 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligenceNarrativeReviewService narrativeReview,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICharacterEnrichmentService characterEnrichment,
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceReviewCandidateService reviewCandidates,
@ -656,10 +655,8 @@ public sealed class OnboardingStoryIntelligenceService(
var knowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(batch, 1, 25);
var narrative = await narrativeReview.GetReviewAsync(batch);
var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID);
var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
var characterReviewComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete;
var characterComplete = characterReviewComplete && characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed;
var characterComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete;
var locationComplete = pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete;
var assetComplete = pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete;
var relationshipComplete = pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete;
@ -702,18 +699,6 @@ public sealed class OnboardingStoryIntelligenceService(
EventCount = narrativeEventCount,
ErrorMessage = plotSynthesisRun.ErrorMessage
},
CharacterEnrichment = characterEnrichmentRun is null
? new CharacterEnrichmentStatusViewModel()
: new CharacterEnrichmentStatusViewModel
{
CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID,
Status = characterEnrichmentRun.Status,
TotalCharacters = characterEnrichmentRun.TotalCharacters,
ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters,
CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty,
CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty,
ErrorMessage = characterEnrichmentRun.ErrorMessage
},
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
@ -721,7 +706,7 @@ public sealed class OnboardingStoryIntelligenceService(
SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
CharacterStageComplete = characterReviewComplete,
CharacterStageComplete = characterComplete,
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
LocationStageComplete = locationComplete,
@ -769,8 +754,8 @@ public sealed class OnboardingStoryIntelligenceService(
public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25, string scope = "significant")
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize, scope));
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize));
public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize));
@ -851,9 +836,6 @@ public sealed class OnboardingStoryIntelligenceService(
var sceneAnalysisComplete = SceneAnalysisComplete(summary);
var narrativePendingCount = narrativeComplete ? 0 : summary.NarrativePendingCount;
var hasCommittedScenes = summary.CompletedRunCount > 0 || summary.CompletedScenes > 0;
var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID);
var characterEnrichmentComplete = characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed;
var characterPanelComplete = characterComplete && characterEnrichmentComplete;
var model = new StoryIntelligenceProgressViewModel
{
@ -869,7 +851,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
HasCommittedScenes = hasCommittedScenes,
CanImport = !characterComplete,
IsComplete = characterPanelComplete,
IsComplete = characterComplete,
SummaryNeedsAttentionCount = characterComplete ? 0 : ReadyPendingCount(summary.CharacterCandidateGenerationStatus, summary.CharacterPendingCount)
},
LocationReview = new StoryIntelligenceLocationReviewViewModel
@ -877,11 +859,7 @@ public sealed class OnboardingStoryIntelligenceService(
HasCommittedScenes = hasCommittedScenes,
CanImport = !locationComplete,
IsComplete = locationComplete,
TotalCandidateCount = summary.LocationCandidateCount ?? 0,
PendingCandidateCount = summary.LocationPendingCount ?? 0,
SignificantCount = summary.LocationSignificantCount,
UncertainCount = summary.LocationUncertainCount,
SummaryNeedsAttentionCount = summary.LocationUncertainCount
SummaryNeedsAttentionCount = locationComplete ? 0 : ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount)
},
AssetReview = new StoryIntelligenceAssetReviewViewModel
{
@ -920,18 +898,6 @@ public sealed class OnboardingStoryIntelligenceService(
EventCount = summary.NarrativeEventCount,
ErrorMessage = summary.WholeBookPlotSynthesisErrorMessage
},
CharacterEnrichment = characterEnrichmentRun is null
? new CharacterEnrichmentStatusViewModel()
: new CharacterEnrichmentStatusViewModel
{
CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID,
Status = characterEnrichmentRun.Status,
TotalCharacters = characterEnrichmentRun.TotalCharacters,
ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters,
CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty,
CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty,
ErrorMessage = characterEnrichmentRun.ErrorMessage
},
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = summary.CompletedRunCount,
@ -1026,7 +992,7 @@ public sealed class OnboardingStoryIntelligenceService(
var pendingCharacterCandidates = await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters);
if (pendingCharacterCandidates == 0)
{
await QueuePostCharacterReviewAnalysisAsync(batch);
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
await reviewCandidates.PreparePeerCandidatesAsync(batch);
}
return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
@ -1052,7 +1018,7 @@ public sealed class OnboardingStoryIntelligenceService(
if (pendingCount == 0)
{
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters);
await QueuePostCharacterReviewAnalysisAsync(batch);
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
await reviewCandidates.PreparePeerCandidatesAsync(batch);
}
else
@ -1085,19 +1051,6 @@ public sealed class OnboardingStoryIntelligenceService(
};
}
private async Task QueuePostCharacterReviewAnalysisAsync(OnboardingStoryIntelligenceBatch batch)
{
// Keep both post-character-review jobs independent; previous direct call was await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync.
await Task.WhenAll(
wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID),
characterEnrichment.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID));
logger.LogInformation(
"Queued post-character-review Story Intelligence jobs. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID}",
batch.BatchID,
batch.ProjectID,
batch.BookID);
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);

View File

@ -178,7 +178,6 @@ public sealed class PersistedStoryIntelligenceRunner(
totalTokens: totals.TotalTokens);
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
var rollingTemporalContext = StoryIntelligenceTemporalResolver.CreateChapterContext(run.SourceLabel);
foreach (var block in sceneBlocks)
{
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
@ -231,8 +230,7 @@ public sealed class PersistedStoryIntelligenceRunner(
var sceneStopwatch = Stopwatch.StartNew();
var promptStopwatch = Stopwatch.StartNew();
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence;
var sceneContextJson = StoryIntelligenceTemporalResolver.AddRollingContextToPromptJson(block.SceneContextJson, rollingTemporalContext);
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, sceneContextJson, block.SceneText);
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
promptStopwatch.Stop();
var openAiStopwatch = Stopwatch.StartNew();
var sceneClientResult = await client.ExecutePromptAsync(
@ -256,7 +254,6 @@ public sealed class PersistedStoryIntelligenceRunner(
parseStopwatch.Stop();
var validationStopwatch = Stopwatch.StartNew();
var validation = sceneValidator.Validate(sceneAttempt.Parsed);
rollingTemporalContext = StoryIntelligenceTemporalResolver.Resolve(sceneAttempt.Parsed, rollingTemporalContext).NextContext;
LogRecoveredJsonWarnings(run, $"Scene {block.TemporarySceneNumber:N0}", sceneAttempt.Warnings);
hasWarnings = hasWarnings || validation.Warnings.Count > 0;

View File

@ -54,11 +54,6 @@ public sealed class PersistedStoryIntelligenceWorker(
processedRun = await ProcessWholeBookPlotSynthesisAsync(stoppingToken);
}
if (!processedRun)
{
processedRun = await ProcessCharacterEnrichmentAsync(stoppingToken);
}
if (!processedRun)
{
await Task.Delay(pollDelay, stoppingToken);
@ -73,13 +68,6 @@ public sealed class PersistedStoryIntelligenceWorker(
return await synthesis.ProcessNextAsync(stoppingToken);
}
private async Task<bool> ProcessCharacterEnrichmentAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
var enrichment = scope.ServiceProvider.GetRequiredService<ICharacterEnrichmentService>();
return await enrichment.ProcessNextAsync(stoppingToken);
}
private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();

View File

@ -1,5 +1,4 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
@ -27,59 +26,27 @@ public sealed class StoryIntelligenceGlobalProgressService(
public async Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)
{
var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId);
var books = new List<StoryIntelligenceGlobalProgressBookViewModel>();
foreach (var summary in summaries)
{
var bookRuns = summary.BookID.HasValue
? await runs.ListRunsByBookForUserAsync(summary.BookID.Value, userId)
: [];
var relevantRuns = bookRuns
.Where(run => run.CreatedUtc >= summary.FirstCreatedUtc
&& run.Status is StoryIntelligenceRunStatuses.Pending
or StoryIntelligenceRunStatuses.Running
or StoryIntelligenceRunStatuses.Completed
or StoryIntelligenceRunStatuses.CompletedWithWarnings
or StoryIntelligenceRunStatuses.Failed
or StoryIntelligenceRunStatuses.Cancelled)
.Select(run => new StoryIntelligenceGlobalProgressRunViewModel
{
RunID = run.StoryIntelligenceRunID,
ProjectID = run.ProjectID,
BookID = run.BookID,
BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle,
Status = string.IsNullOrWhiteSpace(run.Status) ? StoryIntelligenceRunStatuses.Pending : run.Status,
TotalDetectedScenes = run.TotalDetectedScenes ?? 0,
CompletedScenes = run.CompletedScenes ?? 0,
FailedScenes = run.FailedScenes ?? 0,
TotalDurationMs = run.TotalDurationMs ?? 0,
UpdatedUtc = run.UpdatedUtc
})
.ToList();
books.Add(new StoryIntelligenceGlobalProgressBookViewModel
{
ProjectID = summary.ProjectID,
BookID = summary.BookID,
BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle,
RunCount = summary.RunCount,
PendingRunCount = summary.PendingRunCount,
RunningRunCount = summary.RunningRunCount,
CompletedRunCount = summary.CompletedRunCount,
FailedRunCount = summary.FailedRunCount,
TotalExpectedScenes = summary.TotalExpectedScenes,
CompletedScenes = summary.CompletedScenes,
FailedScenes = summary.FailedScenes,
ActiveScenes = summary.ActiveScenes,
Status = summary.Status,
TotalDurationMs = summary.TotalDurationMs,
UpdatedUtc = summary.UpdatedUtc,
Runs = relevantRuns
});
}
return new StoryIntelligenceGlobalProgressViewModel
{
Books = books
Books = summaries.Select(summary => new StoryIntelligenceGlobalProgressBookViewModel
{
ProjectID = summary.ProjectID,
BookID = summary.BookID,
BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle,
RunCount = summary.RunCount,
PendingRunCount = summary.PendingRunCount,
RunningRunCount = summary.RunningRunCount,
CompletedRunCount = summary.CompletedRunCount,
FailedRunCount = summary.FailedRunCount,
TotalExpectedScenes = summary.TotalExpectedScenes,
CompletedScenes = summary.CompletedScenes,
FailedScenes = summary.FailedScenes,
ActiveScenes = summary.ActiveScenes,
Status = summary.Status,
TotalDurationMs = summary.TotalDurationMs,
UpdatedUtc = summary.UpdatedUtc
})
.ToList()
};
}
}

View File

@ -140,8 +140,6 @@ public sealed class StoryIntelligenceImportCommitService(
var timeModeId = FindId(lookupData.TimeModes, item => item.TimeModeName, item => item.TimeModeID, "Relative", "Unknown");
var timeConfidenceId = FindId(lookupData.TimeConfidences, item => item.TimeConfidenceName, item => item.TimeConfidenceID, "Implied", "Unknown");
var timeModeIds = lookupData.TimeModes.ToDictionary(item => item.TimeModeName, item => item.TimeModeID, StringComparer.OrdinalIgnoreCase);
var timeConfidenceIds = lookupData.TimeConfidences.ToDictionary(item => item.TimeConfidenceName, item => item.TimeConfidenceID, StringComparer.OrdinalIgnoreCase);
var revisionStatusId = FindId(lookupData.RevisionStatuses, item => item.StatusName, item => item.RevisionStatusID, "Planned");
var timelineNoteTypeId = noteTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Timeline Note", StringComparison.OrdinalIgnoreCase))?.SceneNoteTypeID;
if (timelineNoteTypeId is null)
@ -160,7 +158,7 @@ public sealed class StoryIntelligenceImportCommitService(
? await storyRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var canonicalById = canonicalScenes.ToDictionary(scene => scene.SceneID);
var rawImportScenes = new List<StoryIntelligenceSceneImportItem>();
var importScenes = new List<StoryIntelligenceSceneImportItem>();
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
{
canonicalById.TryGetValue(sceneResult.SceneID ?? 0, out var canonicalScene);
@ -175,23 +173,13 @@ public sealed class StoryIntelligenceImportCommitService(
}
warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} will be created with limited detail because some analysis could not be read.");
rawImportScenes.Add(fallback);
importScenes.Add(fallback);
continue;
}
rawImportScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
}
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var importScenes = ApplyTemporalResolution(
rawImportScenes,
StoryIntelligenceTemporalResolver.CreateChapterContext(chapter?.ChapterTitle, chapter?.Summary),
timeModeIds,
timeConfidenceIds,
timeModeId,
timeConfidenceId,
warnings);
if (importScenes.Count == 0)
{
blockers.Add("No analysed scene results are available for this chapter.");
@ -207,6 +195,7 @@ public sealed class StoryIntelligenceImportCommitService(
var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None";
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var chapterLabel = chapter is null
? "None"
: string.IsNullOrWhiteSpace(chapter.ChapterTitle)
@ -339,67 +328,6 @@ public sealed class StoryIntelligenceImportCommitService(
};
}
private static IReadOnlyList<StoryIntelligenceSceneImportItem> ApplyTemporalResolution(
IReadOnlyList<StoryIntelligenceSceneImportItem> scenes,
StoryIntelligenceTemporalContext initialContext,
IReadOnlyDictionary<string, int> timeModeIds,
IReadOnlyDictionary<string, int> timeConfidenceIds,
int defaultTimeModeId,
int defaultTimeConfidenceId,
ICollection<string> warnings)
{
var context = initialContext;
var resolvedScenes = new List<StoryIntelligenceSceneImportItem>(scenes.Count);
foreach (var scene in scenes.OrderBy(item => item.TemporarySceneNumber))
{
var resolved = StoryIntelligenceTemporalResolver.Resolve(scene.ParsedScene, context);
context = resolved.NextContext;
if (!timeModeIds.TryGetValue(resolved.TimeModeName, out var resolvedTimeModeId))
{
resolvedTimeModeId = defaultTimeModeId;
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time mode because '{resolved.TimeModeName}' was not available.");
}
if (!timeConfidenceIds.TryGetValue(resolved.TimeConfidenceName, out var resolvedTimeConfidenceId))
{
resolvedTimeConfidenceId = defaultTimeConfidenceId;
warnings.Add($"Scene {scene.TemporarySceneNumber:N0} chronology used fallback time confidence because '{resolved.TimeConfidenceName}' was not available.");
}
resolvedScenes.Add(new StoryIntelligenceSceneImportItem
{
SceneResultID = scene.SceneResultID,
SceneID = scene.SceneID,
TemporarySceneNumber = scene.TemporarySceneNumber,
StartParagraph = scene.StartParagraph,
EndParagraph = scene.EndParagraph,
SourceLabel = scene.SourceLabel,
ParsedScene = scene.ParsedScene,
PovCharacterID = scene.PovCharacterID,
PurposeTypeIDs = scene.PurposeTypeIDs,
Metrics = scene.Metrics,
SceneTitle = scene.SceneTitle,
Summary = scene.Summary,
PurposeNotes = scene.PurposeNotes,
OutcomeNotes = scene.OutcomeNotes,
ImportNoteText = scene.ImportNoteText,
TimeModeID = resolvedTimeModeId,
StartDateTime = resolved.StartDateTime,
EndDateTime = resolved.EndDateTime,
DurationAmount = resolved.DurationAmount,
DurationUnitID = resolved.DurationUnitID,
RelativeTimeText = resolved.RelativeTimeText,
TimeConfidenceID = resolvedTimeConfidenceId,
AnalysisStatus = scene.AnalysisStatus,
HasWarnings = scene.HasWarnings
});
}
return resolvedScenes;
}
private static IReadOnlyList<StoryIntelligenceSceneImportItem> BuildReviewScenes(
IReadOnlyList<Scene> canonicalScenes,
IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults,

View File

@ -149,7 +149,6 @@ public sealed class StoryIntelligenceLocationImportService(
LeafLocationName = candidate.LeafName,
ImportName = candidate.LeafName,
Category = candidate.Category,
Significance = LocationSignificanceService.Assess(candidate.Category, candidate.SignificanceEvidence),
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
ParentLocationHint = candidate.ParentLocationHint,
@ -464,13 +463,7 @@ public sealed class StoryIntelligenceLocationImportService(
};
}
public async Task<IReadOnlyDictionary<string, LocationSignificanceAssessment>> BuildSignificanceAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch, includeLinked: true);
return data.Candidates.ToDictionary(x => x.Key, x => LocationSignificanceService.Assess(x.Category, x.SignificanceEvidence), StringComparer.OrdinalIgnoreCase);
}
private async Task<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, bool includeLinked = false)
private async Task<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, LocationCandidate>(StringComparer.OrdinalIgnoreCase);
@ -521,30 +514,16 @@ public sealed class StoryIntelligenceLocationImportService(
alreadyLinked++;
}
importedScene.BookID = batch.BookID;
AddSceneLocations(groups, existingIndex, importedScene, parsed);
}
}
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
LinkParentCandidates(groups);
// A house hosts events in its rooms. Roll up actual child settings, never unrelated mentions.
var directEvidence = groups.Values.ToDictionary(x => x.Key, x => x.SignificanceEvidence.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var child in groups.Values)
{
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { child.Key };
var parentKey = child.ProposedParentCandidateKey;
while (parentKey is not null && groups.TryGetValue(parentKey, out var parent) && visited.Add(parentKey))
{
parent.SignificanceEvidence.AddRange(directEvidence[child.Key].Where(x => x.IsSetting)
.Select(x => x with { Reasons = new[] { $"Within child setting: {child.DisplayName}" }.Concat(x.Reasons).ToList() }));
parentKey = parent.ProposedParentCandidateKey;
}
}
var candidates = groups.Values
.Where(candidate => includeLinked || candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(candidate => IsVisibleLocationCandidate(candidate))
.OrderByDescending(candidate => LocationSignificanceService.Assess(candidate.Category, candidate.SignificanceEvidence).Score)
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
@ -658,7 +637,6 @@ public sealed class StoryIntelligenceLocationImportService(
candidate.Aliases.Add(cleanName);
}
candidate.SignificanceEvidence.Add(LocationSignificanceService.Observe(importedScene, parsed, BuildSceneReference(importedScene), isSceneSetting, presentInScene, mentionedOnly, notes));
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.Description ??= Clean(notes);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName);
@ -718,7 +696,6 @@ public sealed class StoryIntelligenceLocationImportService(
groups[parentKey] = parentCandidate;
}
parentCandidate.SignificanceEvidence.Add(LocationSignificanceService.Observe(importedScene, parsed, BuildSceneReference(importedScene), false, true, false, null));
parentCandidate.Confidence = Max(parentCandidate.Confidence, 0.9m);
parentCandidate.Description ??= "Parent location inferred from child location context.";
parentCandidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, parentIdentity.DisplayName);
@ -1889,7 +1866,6 @@ public sealed class StoryIntelligenceLocationImportService(
public string? ProposedParentCandidateKey { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<LocationAppearanceImport> Appearances { get; } = [];
public List<LocationSignificanceEvidence> SignificanceEvidence { get; } = [];
public decimal? Confidence { get; set; }
public string? Description { get; set; }
public string? FirstAppearanceNote { get; set; }

View File

@ -15,7 +15,6 @@ public sealed class StoryIntelligenceMaterialisationService(
ISceneMetricTypeRepository metricTypes,
ISceneRepository scenes,
IChapterRepository chapters,
ILookupRepository lookups,
IStoryIntelligenceNarrativeReviewService narrative,
IStoryIntelligenceReviewCandidateService reviewCandidates,
ILogger<StoryIntelligenceMaterialisationService> logger) : IStoryIntelligenceMaterialisationService
@ -86,21 +85,12 @@ public sealed class StoryIntelligenceMaterialisationService(
.OrderBy(metric => metric.SortOrder)
.ThenBy(metric => metric.MetricName)
.ToList();
var lookupData = await lookups.GetAllAsync();
var timeModeIds = lookupData.TimeModes.ToDictionary(item => item.TimeModeName, item => item.TimeModeID, StringComparer.OrdinalIgnoreCase);
var timeConfidenceIds = lookupData.TimeConfidences.ToDictionary(item => item.TimeConfidenceName, item => item.TimeConfidenceID, StringComparer.OrdinalIgnoreCase);
var metricMap = BuildMetricMap(configuredMetrics);
var metricValuesSaved = 0;
var chronologySaved = 0;
var sceneResultsRead = 0;
var sceneResultsParsed = 0;
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
var canonicalSceneMap = canonicalScenes
.Where(scene => !scene.IsArchived)
.GroupBy(scene => scene.SceneID)
.ToDictionary(group => group.Key, group => group.First());
var chapterMap = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
foreach (var run in scopedRuns.Where(run => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
@ -117,9 +107,7 @@ public sealed class StoryIntelligenceMaterialisationService(
});
}
chapterMap.TryGetValue(run.ChapterID ?? 0, out var chapter);
var temporalContext = StoryIntelligenceTemporalResolver.CreateChapterContext(chapter?.ChapterTitle, chapter?.Summary);
foreach (var sceneResult in (await results.ListSceneResultsAsync(run.StoryIntelligenceRunID)).OrderBy(result => result.TemporarySceneNumber))
foreach (var sceneResult in await results.ListSceneResultsAsync(run.StoryIntelligenceRunID))
{
cancellationToken.ThrowIfCancellationRequested();
sceneResultsRead++;
@ -135,17 +123,6 @@ public sealed class StoryIntelligenceMaterialisationService(
}
sceneResultsParsed++;
var temporal = StoryIntelligenceTemporalResolver.Resolve(parsed, temporalContext);
temporalContext = temporal.NextContext;
if (sceneResult.SceneID is int sceneId
&& canonicalSceneMap.TryGetValue(sceneId, out var canonicalScene)
&& !HasExistingChronology(canonicalScene)
&& TryApplyTemporalResolution(canonicalScene, temporal, timeModeIds, timeConfidenceIds))
{
await scenes.SaveAsync(canonicalScene);
chronologySaved++;
}
var metrics = BuildMetrics(parsed, metricMap);
if (metrics.Count == 0)
{
@ -179,14 +156,13 @@ public sealed class StoryIntelligenceMaterialisationService(
}
logger.LogInformation(
"Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} ChronologySaved={ChronologySaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.",
"Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.",
bookId,
pipeline.StoryIntelligenceBookPipelineID,
scopedRuns.Count,
sceneResultsRead,
sceneResultsParsed,
metricValuesSaved,
chronologySaved,
narrativeCandidatesBefore,
narrativeCandidatesAfter);
@ -294,7 +270,7 @@ public sealed class StoryIntelligenceMaterialisationService(
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
if (direct?.Summary is not null || direct?.TemporalAnalysis is not null || direct?.Setting is not null || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0)
if (direct?.Summary is not null || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0)
{
return direct;
}
@ -314,40 +290,6 @@ public sealed class StoryIntelligenceMaterialisationService(
return null;
}
private static bool TryApplyTemporalResolution(
Scene scene,
StoryIntelligenceTemporalResolution temporal,
IReadOnlyDictionary<string, int> timeModeIds,
IReadOnlyDictionary<string, int> timeConfidenceIds)
{
if (temporal.StartDateTime is null && string.IsNullOrWhiteSpace(temporal.RelativeTimeText))
{
return false;
}
if (!timeModeIds.TryGetValue(temporal.TimeModeName, out var timeModeId)
|| !timeConfidenceIds.TryGetValue(temporal.TimeConfidenceName, out var timeConfidenceId))
{
return false;
}
scene.TimeModeID = timeModeId;
scene.StartDateTime = temporal.StartDateTime;
scene.EndDateTime = temporal.EndDateTime;
scene.DurationAmount = temporal.DurationAmount;
scene.DurationUnitID = temporal.DurationUnitID;
scene.RelativeTimeText = temporal.RelativeTimeText;
scene.TimeConfidenceID = timeConfidenceId;
return true;
}
private static bool HasExistingChronology(Scene scene)
=> scene.StartDateTime.HasValue
|| scene.EndDateTime.HasValue
|| scene.DurationAmount.HasValue
|| scene.DurationUnitID.HasValue
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText);
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty

View File

@ -7,7 +7,6 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceProgressNotifier
{
Task PublishAsync(StoryIntelligenceRunProgressEvent progress);
Task PublishCharacterEnrichmentAsync(CharacterEnrichmentProgressEvent progress);
Task PublishGlobalAsync(int userId);
}
@ -24,13 +23,6 @@ public sealed class StoryIntelligenceProgressNotifier(
await PublishGlobalAsync(progress.UserID);
}
public async Task PublishCharacterEnrichmentAsync(CharacterEnrichmentProgressEvent progress)
{
var userGroup = StoryIntelligenceHub.UserGroup(progress.UserID);
await hub.Clients.Group(userGroup)
.SendAsync("CharacterEnrichmentProgressChanged", progress);
}
public async Task PublishGlobalAsync(int userId)
{
var userGroup = StoryIntelligenceHub.UserGroup(userId);

View File

@ -55,22 +55,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
RelationshipTypeOptions = typeOptions,
RelationshipStateOptions = lookupData.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{
Key = candidate.Key,
ReviewRulesVersion = 3,
ProposalKind = candidate.ProposalKind,
RelationshipState = candidate.RelationshipState,
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipState),
IsReciprocal = candidate.IsReciprocal,
IsKnownToOtherCharacter = candidate.IsKnownToOtherCharacter,
Intensity = candidate.Intensity,
Development = candidate.Development,
NeedsDirectionReview = candidate.NeedsDirectionReview,
FirstChapterNumber = CandidateFirstStoryOrder(candidate).ChapterNumber,
FirstSceneNumber = CandidateFirstStoryOrder(candidate).SceneNumber,
FirstSceneID = CandidateFirstStoryOrder(candidate).SceneID,
CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType,
@ -95,61 +82,190 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing relationships." };
}
var choices = form.Relationships;
var candidates = data.Candidates.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
var lookups = await characters.GetLookupsAsync(batch.ProjectID);
// Validate the whole page before making any canonical writes.
if (choices.Select(x => x.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != choices.Count
|| choices.Any(x => !candidates.ContainsKey(x.Key)
|| x.Action is not (StoryIntelligenceRelationshipImportActions.CreateNew or StoryIntelligenceRelationshipImportActions.LinkExisting or StoryIntelligenceRelationshipImportActions.Ignore)
|| (x.Action != StoryIntelligenceRelationshipImportActions.Ignore
&& (!lookups.RelationshipTypes.Any(t => t.RelationshipTypeID == x.RelationshipTypeID)
|| !lookups.RelationshipStates.Any(t => t.RelationshipStateID == x.RelationshipStateID)
|| x.Intensity is < 1 or > 10
|| string.IsNullOrWhiteSpace(x.Development)))))
return new() { Success = false, Message = "The proposals changed or a value is invalid. Reload review and check the state, type and description before saving." };
var accepted = 0;
var ignored = 0;
foreach (var choice in choices)
var choices = form.Relationships
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
SaveRelationshipSelections(batch, choices.Values);
if (choices.Count == 0)
{
choice.AcceptedRelationshipID = null;
var candidate = candidates[choice.Key];
if (choice.Action == StoryIntelligenceRelationshipImportActions.Ignore)
if (data.Candidates.Count == 0)
{
batch.RelationshipStageComplete = completeReview;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Relationship review completed. No relationship decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one relationship to create, link or ignore." };
}
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
var created = 0;
var linkedExisting = 0;
var merged = 0;
var ignored = 0;
var eventsCreated = 0;
var resolvedRelationships = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
ignored++;
AddDecision(batch, candidate.Key, choice.Action, null, RelationshipLabel(candidate), false);
continue;
}
var appearance = candidate.Appearances.Single();
var id = await characters.AcceptRelationshipDevelopmentAsync(new RelationshipDevelopmentAcceptance
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ProjectID = batch.ProjectID, BookID = batch.BookID, SceneID = appearance.SceneID,
SourceReviewKey = candidate.Key,
CharacterAID = choice.ReverseDirection ? candidate.CharacterBID : candidate.CharacterAID,
CharacterBID = choice.ReverseDirection ? candidate.CharacterAID : candidate.CharacterBID,
RelationshipTypeID = choice.RelationshipTypeID!.Value,
RelationshipStateID = choice.RelationshipStateID!.Value,
IsPermanent = candidate.ProposalKind == "Factual",
IsReciprocal = choice.IsReciprocal ?? candidate.IsReciprocal,
IsKnownToOtherCharacter = choice.IsKnownToOtherCharacter ?? candidate.IsKnownToOtherCharacter,
Intensity = choice.Intensity, Description = choice.Development!.Trim()
});
choice.AcceptedRelationshipID = id;
accepted++;
AddDecision(batch, candidate.Key, choice.Action, id, RelationshipLabel(candidate), true);
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
? candidate.ExistingRelationshipID
: null;
if (!relationshipId.HasValue)
{
if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value))
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." };
}
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
{
ProjectID = batch.ProjectID,
CharacterAID = candidate.CharacterAID,
CharacterBID = candidate.CharacterBID,
RelationshipTypeID = choice.RelationshipTypeID.Value,
IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(StoryOrder).FirstOrDefault()?.SceneID,
EndSceneID = null,
IsKnownToReader = true,
Notes = BuildRelationshipNotes(candidate),
IsInitialRelationship = false,
InitialBookID = batch.BookID,
ReaderInitiallyKnows = true,
InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType),
InitialIntensity = ConfidenceIntensity(candidate.Confidence),
IsReciprocal = true
});
created++;
}
else
{
linkedExisting++;
}
resolvedRelationships[candidate.Key] = relationshipId.Value;
AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true);
}
batch.RelationshipStageComplete = completeReview && choices.Count == data.Candidates.Count;
batch.LastRelationshipImportResult = new() { RelationshipEventsCreated = accepted, RelationshipsIgnored = ignored };
logger.LogInformation("Accepted {Accepted} relationship developments and ignored {Ignored} for book {BookID}", accepted, ignored, batch.BookID);
return new() { Success = true, ScenesCreated = accepted, Message = $"Saved {accepted} scene relationship proposal(s); ignored {ignored}." };
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidatesByKey.ContainsKey(targetKey)
|| !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId))
{
logger.LogWarning(
"Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
resolvedRelationships[candidate.Key] = targetRelationshipId;
merged++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| !resolvedRelationships.TryGetValue(candidate.Key, out var relationshipId))
{
continue;
}
foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())
.OrderBy(StoryOrder))
{
if (!HasMeaningfulRelationshipEvent(appearance))
{
continue;
}
await characters.SaveRelationshipEventAsync(new RelationshipEvent
{
CharacterRelationshipID = relationshipId,
SceneID = appearance.SceneID,
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType),
Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence),
IsKnownToOtherCharacter = true,
Description = BuildRelationshipEventDescription(candidate, appearance)
});
eventsCreated++;
}
}
batch.RelationshipStageComplete = completeReview;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
{
RelationshipsCreated = created,
RelationshipsLinked = linkedExisting,
RelationshipsMerged = merged,
RelationshipsIgnored = ignored,
RelationshipEventsCreated = eventsCreated
};
logger.LogInformation(
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",
batch.BatchID,
created,
linkedExisting,
merged,
ignored,
eventsCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = eventsCreated,
Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added."
};
}
private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
var existingByPair = existingRelationships;
var existingByPair = existingRelationships
.GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID))
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var groups = new Dictionary<string, RelationshipCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
@ -199,12 +315,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
}
}
// A repeated durable fact is not a new development on every mention.
var candidates = groups.Values
.GroupBy(candidate => candidate.ProposalKind == "Factual"
? $"fact:{candidate.RelationshipType}:{(candidate.IsReciprocal ? PairKey(candidate.CharacterAID, candidate.CharacterBID) : $"{candidate.CharacterAID}>{candidate.CharacterBID}")}"
: candidate.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderBy(CandidateFirstStoryOrder).First())
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.OrderBy(CandidateFirstStoryOrder)
.ThenBy(candidate => candidate.CharacterAName)
@ -217,21 +328,19 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static void AddSceneRelationships(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyList<CharacterRelationship> existingByPair,
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<RelationshipEvent> existingEvents)
{
foreach (var relationship in parsed.Relationships ?? [])
{
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents, relationship);
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents);
}
foreach (var observation in parsed.Observations ?? [])
{
if (!IsRelationshipObservation(observation)
|| (parsed.Relationships ?? []).Any(r =>
PairNamesMatch(r.CharacterA, r.CharacterB, observation.SubjectName, observation.ObjectName)))
if (!IsRelationshipObservation(observation))
{
continue;
}
@ -245,8 +354,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
observation.SubjectName,
observation.ObjectName,
FirstConfigured(observation.Predicate, observation.ObservationType),
string.Join(" ", new[] { observation.Description, observation.Evidence }
.Where(text => !string.IsNullOrWhiteSpace(text)).Distinct(StringComparer.OrdinalIgnoreCase)),
FirstConfigured(observation.Description, observation.Evidence),
observation.Confidence,
existingEvents);
}
@ -255,7 +363,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static void AddRelationship(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyList<CharacterRelationship> existingByPair,
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
string? characterAName,
@ -263,8 +371,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
string? relationshipSignal,
string? evidence,
decimal? confidence,
IReadOnlyList<RelationshipEvent> existingEvents,
SceneIntelligenceRelationship? proposal = null)
IReadOnlyList<RelationshipEvent> existingEvents)
{
var characterA = characterIndex.Find(characterAName, importedScene);
var characterB = characterIndex.Find(characterBName, importedScene);
@ -273,51 +380,52 @@ public sealed class StoryIntelligenceRelationshipImportService(
return;
}
var classification = RelationshipTypeFromEvidence(relationshipSignal, evidence, null, characterA.CharacterName, characterB.CharacterName);
var factual = proposal?.ProposalKind == "Factual"
|| (proposal?.ProposalKind is null && classification.Type is "Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent");
var kind = factual ? "Factual" : proposal?.ProposalKind == "State" ? "State" : "Development";
var appearance = new RelationshipAppearanceImport(importedScene.SceneID, importedScene.ChapterNumber,
importedScene.SceneNumber, importedScene.SortOrder, importedScene.SceneTitle, relationshipSignal, evidence, confidence, false);
if (!factual && (string.IsNullOrWhiteSpace(evidence)
|| (proposal?.ProposalKind is null && !HasMeaningfulRelationshipEvent(appearance)))) return;
var reciprocal = proposal?.IsReciprocal ?? (factual && classification.Type == "Sibling");
var type = factual ? FirstConfigured(proposal?.FactualRelationshipType, classification.Type)! : "Other";
var state = FirstConfigured(proposal?.RelationshipState, RelationshipStateNameFromSignal(relationshipSignal))!;
var key = DevelopmentKey(importedScene.SceneID, characterA.CharacterID, characterB.CharacterID, reciprocal, kind, $"{relationshipSignal}|{state}", evidence);
if (groups.ContainsKey(key)) return;
var existing = existingByPair.FirstOrDefault(r => r.IsReciprocal == reciprocal && r.IsPermanent == factual
&& string.Equals(r.RelationshipTypeName, type, StringComparison.OrdinalIgnoreCase)
&& ((r.CharacterAID == characterA.CharacterID && r.CharacterBID == characterB.CharacterID)
|| (reciprocal && r.CharacterAID == characterB.CharacterID && r.CharacterBID == characterA.CharacterID)));
var candidate = new RelationshipCandidate(key, characterA.CharacterID, characterA.CharacterName,
characterB.CharacterID, characterB.CharacterName, type, existing?.CharacterRelationshipID,
existing is null ? null : $"{existing.CharacterAName} / {existing.RelationshipTypeName} / {existing.CharacterBName}")
var key = PairKey(characterA.CharacterID, characterB.CharacterID);
var classification = RelationshipTypeFromEvidence(
relationshipSignal,
evidence,
importedScene.StructuralSummary,
characterA.CharacterName,
characterB.CharacterName);
if (!groups.TryGetValue(key, out var candidate))
{
ProposalKind = kind, RelationshipState = state, IsReciprocal = reciprocal,
IsKnownToOtherCharacter = proposal?.IsKnownToOtherCharacter ?? false,
Intensity = proposal?.Intensity is >= 1 and <= 10 ? proposal.Intensity : null,
Development = FirstConfigured(proposal?.Development, evidence, relationshipSignal),
NeedsDirectionReview = proposal?.IsReciprocal is null,
Confidence = confidence, FirstAppearanceNote = BuildSceneReference(importedScene),
LastAppearanceNote = BuildSceneReference(importedScene), ExampleScene = BuildFirstAppearance(importedScene, parsed),
ExampleContext = evidence
};
candidate.Appearances.Add(appearance);
groups[key] = candidate;
}
existingByPair.TryGetValue(key, out var existingMatch);
candidate = new RelationshipCandidate(
key,
characterA.CharacterID,
characterA.CharacterName,
characterB.CharacterID,
characterB.CharacterName,
classification.Type,
existingMatch?.CharacterRelationshipID,
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
candidate.RelationshipTypeClassification = classification;
groups[key] = candidate;
}
internal static string DevelopmentKey(int sceneId, int a, int b, bool reciprocal, string kind, string? signal, string? evidence)
{
var participants = reciprocal ? PairKey(a, b) : $"{a}>{b}";
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes($"{Normalise(signal)}|{Normalise(evidence)}")))[..24];
return $"scene:{sceneId}:{participants}:{kind}:{hash}";
}
if (classification.CompareTo(candidate.RelationshipTypeClassification) > 0
|| classification.CompareTo(candidate.RelationshipTypeClassification) == 0 && (confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{
candidate.RelationshipType = classification.Type;
candidate.RelationshipTypeClassification = classification;
}
private static bool PairNamesMatch(string? a, string? b, string? c, string? d)
=> (Normalise(a) == Normalise(c) && Normalise(b) == Normalise(d))
|| (Normalise(a) == Normalise(d) && Normalise(b) == Normalise(c));
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene);
candidate.LastAppearanceNote = BuildSceneReference(importedScene);
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
candidate.ExampleContext ??= Clean(evidence);
candidate.Appearances.Add(new RelationshipAppearanceImport(
importedScene.SceneID,
importedScene.ChapterNumber,
importedScene.SceneNumber,
importedScene.SortOrder,
importedScene.SceneTitle,
relationshipSignal,
evidence,
confidence,
candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value)));
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
@ -409,8 +517,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{
var stateName = states.FirstOrDefault(x => string.Equals(x.StateName, signal, StringComparison.OrdinalIgnoreCase))?.StateName
?? RelationshipStateNameFromSignal(signal);
var stateName = RelationshipStateNameFromSignal(signal);
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.First().RelationshipStateID;
@ -422,9 +529,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return
value.Contains("reconcil") ? "Reconciled" :
value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" :
HasRomanticRelationshipEvidence(signal) ? "Romantic" :
value.Contains("distrust") || value.Contains("mistrust") || value.Contains("suspect") ? "Distrustful" :
value.Contains("protect") ? "Protective" :
value.Contains("romantic") || value.Contains("attraction") || value.Contains("lover") || value.Contains("love") || value.Contains("couple") ? "Romantic" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") || value.Contains("trust") || value.Contains("hug") || value.Contains("gift") ? "Friendly" :
value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" :
value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
@ -444,10 +549,13 @@ public sealed class StoryIntelligenceRelationshipImportService(
string? characterBName)
{
var relationshipText = NormaliseForMatching($"{evidence} {characterAName} {characterBName}");
// Scene-wide romance/conflict can belong to entirely different characters.
var pairContext = ContextForPair(sceneContext, characterAName, characterBName);
var explicitText = NormaliseForMatching($"{evidence} {pairContext}");
var allText = NormaliseForMatching($"{signal} {evidence}");
var explicitText = NormaliseForMatching($"{evidence} {sceneContext} {characterAName} {characterBName}");
var allText = NormaliseForMatching($"{signal} {evidence} {sceneContext} {characterAName} {characterBName}");
if (ContainsAny(relationshipText, "best friend", "close friend", "friend since", "childhood friend"))
{
return new("Friend", 500);
}
if (HasDirectSiblingEvidence(evidence, characterAName, characterBName))
{
return new("Sibling", 490);
@ -458,12 +566,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Parent", 480);
}
if (ContainsAny(relationshipText, "best friend", "best friends", "closest friend")
&& !ContainsAny(relationshipText, "not best friends", "not her best friend", "not his best friend"))
return new("Best Friend", 475);
if (ContainsAny(relationshipText, "close friend", "friend since", "childhood friend"))
return new("Friend", 465);
if (ContainsAny(explicitText, "neighbour", "neighbor", "next door"))
{
return new("Neighbour", 470);
@ -474,7 +576,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Professional", 460);
}
if (ContainsAny(explicitText, "examiner", "instructor", "learner", "teacher", "student", "mentor", "pupil", "tutor"))
if (ContainsAny(explicitText, "examiner", "candidate", "instructor", "learner", "teacher", "student", "mentor", "pupil", "tutor"))
{
return new("Educational", 455);
}
@ -484,21 +586,22 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Family", 420);
}
if (ContainsAny(relationshipText, "rivals", "rivalry", "compete against", "competing against", "competitors", "compete for", "competing for")
&& !ContainsAny(relationshipText, "not rivals", "no rivalry"))
if (ContainsAny(allText,
"rival", "enemy", "adversarial", "conflict", "mistrust", "distrust", "antagonist", "competitor",
"confront", "attack", "attacks", "attacked", "assault", "assaults", "assaulted", "slap", "slaps",
"slapped", "threat", "threaten", "threatens", "threatened", "violence", "never speak", "disgust",
"betray", "betrays", "betrayed", "spiked", "drugged", "grope", "gropes", "groped", "force",
"forces", "forced", "insult", "insults", "anger", "hostile"))
{
return new("Rival", 370);
}
if (ContainsAny(relationshipText, "enemy", "enemies", "attack", "attacks", "attacked", "assault", "assaults", "assaulted", "assaulting", "slap", "slaps",
"slapped", "threaten", "threatens", "threatened", "violence", "drugged", "groped", "hostile enemies"))
return new("Enemy", 365);
if (ContainsAny(relationshipText, "never speak", "estranged from", "cut off contact"))
return new("Estranged From", 355);
if (HasRomanticRelationshipEvidence(evidence))
if (ContainsAny(allText, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "kiss", "proposal", "engaged", "couple"))
{
return new("Romantic", 360);
}
if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner"))
if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner", "candidate"))
{
return new("Educational", 350);
}
@ -543,33 +646,13 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Ally", 250);
}
if (ContainsAny(relationshipText, "has met", "barely knows", "acquaintance", "acquaintances", "knows of", "aware of", "recognises", "recognizes", "met before", "know each other", "knows him", "knows her"))
return new("Acquaintance", 100);
return new("Other", 0);
}
private static string ContextForPair(string? context, string? characterA, string? characterB)
{
if (string.IsNullOrWhiteSpace(characterA) || string.IsNullOrWhiteSpace(characterB)) return string.Empty;
return string.Join(" ", Regex.Split(context ?? string.Empty, @"(?<=[.!?])\s+")
.Where(sentence => ContainsAny(NormaliseForMatching(sentence), characterA)
&& ContainsAny(NormaliseForMatching(sentence), characterB)));
}
private static bool HasRomanticRelationshipEvidence(string? evidence)
{
var value = NormaliseForMatching(evidence);
if (Regex.IsMatch(evidence ?? string.Empty, @"\b(?:about|discusses?|describes?)\b.{0,50}\b(?:boyfriend|girlfriend|lover|romantic relationship)\b", RegexOptions.IgnoreCase)) return false;
if (Regex.IsMatch(evidence ?? string.Empty, @"\b(?:not|no|never|isn't|aren't)\b.{0,25}\b(?:romantic|boyfriend|girlfriend|dating|in love|a couple)\b", RegexOptions.IgnoreCase)) return false;
if (ContainsAny(value, "not romantic", "no romantic", "not a couple", "not dating", "not in love", "unwanted", "forced kiss", "assault", "assaulting", "assaulted", "against her will", "against his will")) return false;
return ContainsAny(value, "romantic relationship", "romantically involved", "in a relationship", "dating each other", "they are dating", "they are a couple", "boyfriend", "girlfriend", "husband", "wife", "spouse", "lovers", "in love with", "has a crush on", "romantic feelings for", "wants to date", "wants a relationship with", "wants a romantic relationship", "hopes for a relationship", "asks her on a date", "asks him on a date", "asks them on a date", "romantic interest in", "romantic attraction to", "engaged to", "married to");
return new("Unknown", 0);
}
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
{
var value = $"{appearance.Signal} {appearance.Evidence}";
if (Regex.IsMatch(value, @"\b(?:merely|simply|only) (?:appear|present|share|co.present)|\bno (?:meaningful )?(?:interaction|change)\b", RegexOptions.IgnoreCase)) return false;
return RelationshipEventWords.Any(word => Regex.IsMatch(value, $@"\b{Regex.Escape(word)}\b", RegexOptions.IgnoreCase));
return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
}
private static bool HasDirectSiblingEvidence(string? evidence, string? characterAName, string? characterBName)
@ -577,33 +660,19 @@ public sealed class StoryIntelligenceRelationshipImportService(
var clean = Clean(evidence);
if (string.IsNullOrWhiteSpace(clean)
|| FigurativeSibling.IsMatch(clean)
|| ReportedThirdPartyKinship.IsMatch(clean)
|| Regex.IsMatch(clean, @"\b(?:not|never)\s+(?:(?:his|her|my|their|a|the)\s+)?(?:sisters?|brothers?|siblings?)\b", RegexOptions.IgnoreCase))
|| ReportedThirdPartyKinship.IsMatch(clean))
{
return false;
}
var normalised = NormaliseForMatching(clean);
return HasNamedSiblingEvidence(normalised, characterAName, characterBName)
|| ContainsAny(normalised, "they are sisters", "they are brothers", "they are siblings", "we are sisters", "we re sisters", "we are brothers", "we re brothers", "between siblings", "the sisters", "the brothers", "sibling relationship", "my brother", "my sister", "her brother", "her sister", "his brother", "his sister", "their brother", "their sister", "my older sister", "my younger sister", "my older brother", "my younger brother", "her older sister", "her younger sister", "his older brother", "his younger brother")
return ContainsAny(normalised, "my brother", "my sister", "her brother", "her sister", "his brother", "his sister", "their brother", "their sister")
|| ContainsAny(normalised, "two siblings", "both siblings", "the siblings")
|| HasOwnedKinshipBetweenParticipants("brother", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sister", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sibling", characterAName, characterBName);
}
private static bool HasNamedSiblingEvidence(string value, string? a, string? b)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
foreach (var (first, second) in new[] { (a, b), (b, a) })
{
if (ContainsAny(value, $"{first} and {second} are sisters", $"{first} and {second} are brothers", $"{first} and {second} are siblings")) return true;
foreach (var kin in new[] { "sister", "brother", "sibling", "older sister", "younger sister", "elder sister", "older brother", "younger brother", "elder brother" })
if (ContainsAny(value, $"{first} is {second}'s {kin}", $"{first} identified as {second}'s {kin}", $"{first} is identified as {second}'s {kin}", $"{first} described as {second}'s {kin}", $"{first} {second}'s {kin}", $"{first}'s {kin} {second}", $"{first} is the {kin} of {second}")) return true;
}
return false;
}
private static bool HasDirectParentEvidence(string? evidence, string? characterAName, string? characterBName)
{
var normalised = NormaliseForMatching(evidence);
@ -697,6 +766,28 @@ public sealed class StoryIntelligenceRelationshipImportService(
.LastOrDefault().StateName ?? initialState;
}
private static string BuildRelationshipNotes(RelationshipCandidate candidate)
{
var lines = new List<string> { "Imported from Story Intelligence relationship review." };
if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
lines.Add(candidate.ExampleContext);
}
if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote))
{
lines.Add($"First detected: {candidate.FirstAppearanceNote}");
}
return string.Join(Environment.NewLine, lines);
}
private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance)
=> FirstConfigured(
Clean(appearance.Evidence),
Clean(appearance.Signal),
$"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate);
private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
@ -708,6 +799,24 @@ public sealed class StoryIntelligenceRelationshipImportService(
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
.ToList();
private static void SaveRelationshipSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
{
foreach (var choice in choices)
{
if (string.IsNullOrWhiteSpace(choice.Key) || choice.RelationshipTypeID is not { } relationshipTypeId)
{
continue;
}
batch.RelationshipSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
batch.RelationshipSelections.Add(new OnboardingStoryIntelligenceRelationshipSelection
{
Key = choice.Key,
RelationshipTypeID = relationshipTypeId
});
}
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
@ -735,6 +844,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
private static int? ConfidenceIntensity(decimal? confidence)
=> confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null;
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
@ -780,11 +895,11 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static readonly string[] RelationshipEventWords =
[
"begins", "begin", "starts", "first meeting", "deteriorates", "deteriorate",
"begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects",
"becomes", "become", "establishes", "couple",
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts", "entrust", "betray", "confides", "asks", "helps"
"becomes", "become", "establishes", "established", "explicit", "couple", "relationship",
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts"
];
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);
@ -799,13 +914,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
int? existingRelationshipId,
string? existingRelationshipLabel)
{
public string ProposalKind { get; init; } = "Development";
public string RelationshipState { get; init; } = "Unknown";
public bool IsReciprocal { get; init; }
public bool IsKnownToOtherCharacter { get; init; }
public bool NeedsDirectionReview { get; init; }
public int? Intensity { get; init; }
public string? Development { get; init; }
public string Key { get; } = key;
public int CharacterAID { get; } = characterAId;
public string CharacterAName { get; } = characterAName;
@ -906,7 +1014,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
{
"Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent" or "Spouse" => 5,
"Neighbour" or "Educational" or "Professional" or "Medical" or "Authority" => 4,
"Best Friend" or "Friend" or "Romantic" or "Criminal" or "Rival" or "Enemy" => 3,
"Friend" or "Romantic" or "Criminal" or "Rival" => 3,
"Family" or "Ally" => 2,
_ => 0
};

View File

@ -12,7 +12,7 @@ public interface IStoryIntelligenceReviewCandidateService
Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant");
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
@ -188,22 +188,10 @@ public sealed class StoryIntelligenceReviewCandidateService(
};
}
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant")
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
scope = scope is "all" or "uncertain" ? scope : "significant";
var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, StoryIntelligenceReviewModules.Locations);
var rows = await candidates.ListLocationsAsync(batch.BookID);
var all = rows.Select(row => {
var item = JsonSerializer.Deserialize<StoryIntelligenceLocationReviewCandidateViewModel>(row.CandidatePayloadJson, JsonOptions)!;
item.DecisionStatus = row.DecisionStatus;
return item;
}).ToList();
var ordered = LocationSignificanceService.OrderHierarchy(all, scope);
pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
pageNumber = Math.Clamp(pageNumber, 1, Math.Max(1, (int)Math.Ceiling(ordered.Count / (double)pageSize)));
var items = ordered.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList();
var pendingCount = all.Count(x => x.IsPending);
var uncertainCount = all.Count(x => x.IsPending && x.NeedsReview);
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
var allPendingPage = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, 1, MaxPageSize);
var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList();
var parentOptions = projectLocations
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
@ -216,25 +204,21 @@ public sealed class StoryIntelligenceReviewCandidateService(
})
.ToList();
var sameLocationOptions = await BuildSameLocationOptionsAsync(projectLocations);
var targetOptions = await BuildLocationTargetOptionsAsync(projectLocations, all.Where(x => x.IsPending).ToList());
var targetOptions = await BuildLocationTargetOptionsAsync(projectLocations, allPendingPage.Items);
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = true,
CanImport = items.Any(x => x.IsPending),
IsComplete = generation?.Status == StoryIntelligenceReviewCandidateGenerationStatuses.Ready && pendingCount == 0,
Scope = scope,
SignificantCount = all.Count(x => x.Significance?.IsSignificant == true),
UncertainCount = uncertainCount,
FilteredCount = ordered.Count,
SummaryNeedsAttentionCount = uncertainCount,
PageNumber = pageNumber,
PageSize = pageSize,
TotalCandidateCount = all.Count,
PendingCandidateCount = pendingCount,
CanImport = page.Items.Count > 0,
IsComplete = page.GenerationReady && page.PendingCount == 0,
SummaryNeedsAttentionCount = page.PendingCount,
PageNumber = page.PageNumber,
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
ParentLocationOptions = parentOptions,
SameLocationOptions = sameLocationOptions,
LocationTargetOptions = targetOptions,
Candidates = items
Candidates = page.Items
};
}
@ -273,7 +257,6 @@ public sealed class StoryIntelligenceReviewCandidateService(
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
RelationshipStateOptions = lookupData.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
RelationshipTypeOptions = lookupData.RelationshipTypes
.OrderBy(type => type.SortOrder)
.ThenBy(type => type.TypeName)
@ -509,7 +492,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
StableKey = choice.Key,
DecisionAction = choice.Action,
DecisionTargetKey = choice.AliasTargetKey,
DecisionCanonicalID = choice.AcceptedRelationshipID
DecisionCanonicalID = choice.RelationshipTypeID
});
}

View File

@ -89,13 +89,6 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
var relationships = Arr(Obj(Props(
("characterA", Str()),
("characterB", Str()),
("proposalKind", Enum("Factual", "State", "Development")),
("factualRelationshipType", NullableString()),
("relationshipState", NullableString()),
("isReciprocal", Bool()),
("isKnownToOtherCharacter", Bool()),
("intensity", NullableInteger()),
("development", Str()),
("relationshipSignal", Enum("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation")),
("evidence", Str()),
("confidence", Confidence()))));
@ -113,18 +106,6 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
("absoluteDate", NullableString()),
("evidence", Str()),
("confidence", Confidence()))));
var temporalAnalysis = Obj(Props(
("absoluteDate", NullableString()),
("exactTime", NullableString()),
("partOfDay", NullableString()),
("relativeTransition", NullableEnum("same", "later", "next", "following", "after", "before", "elapsed", "unknown")),
("relativeAmount", NullableInteger()),
("relativeUnit", NullableEnum("minute", "hour", "day", "week", "month", "year", "unknown")),
("targetDayOfWeek", NullableEnum("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")),
("crossesMidnight", NullableBoolean()),
("evidence", NullableString()),
("confidence", Confidence()),
("unresolvedReason", NullableString())));
var narrativeArcs = Arr(Obj(Props(
("title", Str()),
("description", Str()),
@ -169,7 +150,6 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
("relationships", relationships),
("knowledgeChanges", knowledgeChanges),
("timelineClues", timelineClues),
("temporalAnalysis", temporalAnalysis),
("narrativeArcs", narrativeArcs),
("questionsRaised", questionsRaised),
("questionsAnswered", questionsAnswered),
@ -216,9 +196,6 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
private static Dictionary<string, object> Bool()
=> new() { ["type"] = "boolean" };
private static Dictionary<string, object> NullableBoolean()
=> new() { ["type"] = new[] { "boolean", "null" } };
private static Dictionary<string, object> Confidence()
=> new() { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 };

View File

@ -1,599 +0,0 @@
using System.Globalization;
using System.Text.Json;
using PlotLine.Models;
namespace PlotLine.Services;
public static class StoryIntelligenceTemporalResolver
{
private static readonly CultureInfo[] DateCultures = [CultureInfo.InvariantCulture, CultureInfo.GetCultureInfo("en-GB"), CultureInfo.GetCultureInfo("en-US")];
private static readonly string[] DateFormats =
[
"d MMM yyyy",
"dd MMM yyyy",
"d MMMM yyyy",
"dd MMMM yyyy",
"MMM d yyyy",
"MMMM d yyyy",
"yyyy-MM-dd"
];
private static readonly Dictionary<string, DayOfWeek> DaysOfWeek = new(StringComparer.OrdinalIgnoreCase)
{
["Monday"] = DayOfWeek.Monday,
["Tuesday"] = DayOfWeek.Tuesday,
["Wednesday"] = DayOfWeek.Wednesday,
["Thursday"] = DayOfWeek.Thursday,
["Friday"] = DayOfWeek.Friday,
["Saturday"] = DayOfWeek.Saturday,
["Sunday"] = DayOfWeek.Sunday
};
private static readonly Dictionary<string, int> ClockWords = new(StringComparer.OrdinalIgnoreCase)
{
["one"] = 1,
["two"] = 2,
["three"] = 3,
["four"] = 4,
["five"] = 5,
["six"] = 6,
["seven"] = 7,
["eight"] = 8,
["nine"] = 9,
["ten"] = 10,
["eleven"] = 11,
["twelve"] = 12
};
private static readonly Dictionary<string, int> MinuteWords = new(StringComparer.OrdinalIgnoreCase)
{
["one"] = 1,
["two"] = 2,
["three"] = 3,
["four"] = 4,
["five"] = 5,
["six"] = 6,
["seven"] = 7,
["eight"] = 8,
["nine"] = 9,
["ten"] = 10,
["eleven"] = 11,
["twelve"] = 12,
["thirteen"] = 13,
["fourteen"] = 14,
["fifteen"] = 15,
["sixteen"] = 16,
["seventeen"] = 17,
["eighteen"] = 18,
["nineteen"] = 19,
["twenty"] = 20,
["thirty"] = 30,
["forty"] = 40,
["fifty"] = 50
};
public static StoryIntelligenceTemporalContext CreateChapterContext(string? chapterTitle, string? chapterSummary = null, string? sourceLabel = null)
{
var anchor = ParseDate(FirstNonBlank(chapterTitle, sourceLabel, chapterSummary));
return new StoryIntelligenceTemporalContext(anchor, null, null);
}
public static StoryIntelligenceTemporalResolution Resolve(
SceneIntelligenceScene? scene,
StoryIntelligenceTemporalContext context)
{
var temporal = scene?.TemporalAnalysis;
var fallbackText = FirstNonBlank(
temporal?.Evidence,
temporal?.AbsoluteDate,
temporal?.ExactTime,
temporal?.PartOfDay,
scene?.Setting?.DateOrTimeReference,
scene?.Setting?.TimeOfDay,
scene?.TimelineClues?.Select(clue => FirstNonBlank(clue.Clue, clue.AbsoluteDate, clue.RelativeOrder)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)));
if (temporal is null)
{
temporal = InferFromLegacyFields(scene);
}
var hasCurrentTemporalSignal = HasSceneTemporalSignal(temporal, scene);
var confidence = NormaliseConfidence(temporal?.Confidence);
var contextDate = context.LastDate ?? context.ChapterAnchorDate;
var absoluteDate = ParseDate(temporal?.AbsoluteDate, contextDate) ?? ParseDate(fallbackText, contextDate);
var baseDate = absoluteDate ?? context.LastDate ?? context.ChapterAnchorDate;
var resolvedDate = absoluteDate;
var isDateDeterministic = absoluteDate.HasValue;
var relativeText = FirstNonBlank(temporal?.Evidence, temporal?.AbsoluteDate, scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay);
var currentPartOfDay = CleanPartOfDay(temporal?.PartOfDay ?? scene?.Setting?.TimeOfDay ?? fallbackText);
var inheritedPartOfDay = !hasCurrentTemporalSignal && context.LastDate.HasValue ? context.LastPartOfDay : null;
var displayPartOfDay = currentPartOfDay ?? inheritedPartOfDay;
var partOfDayForTime = currentPartOfDay ?? context.LastPartOfDay;
var exactTime = ParseTime(FirstNonBlank(temporal?.ExactTime, temporal?.Evidence, scene?.Setting?.DateOrTimeReference), partOfDayForTime);
if (!resolvedDate.HasValue && baseDate.HasValue)
{
if (!hasCurrentTemporalSignal)
{
resolvedDate = baseDate.Value.Date;
isDateDeterministic = true;
}
else if (temporal?.CrossesMidnight == true)
{
resolvedDate = baseDate.Value.AddDays(1);
isDateDeterministic = true;
}
else if (TryResolveRelativeDate(baseDate.Value, temporal, fallbackText, out var relativeDate, out var relativeIsExact))
{
resolvedDate = relativeDate;
isDateDeterministic = relativeIsExact;
}
else if (IsSameDayReference(temporal, fallbackText))
{
resolvedDate = baseDate.Value.Date;
isDateDeterministic = true;
}
else if (exactTime.HasValue)
{
resolvedDate = baseDate.Value.Date;
isDateDeterministic = true;
}
else if (!HasApproximateSignal(temporal, fallbackText) && (currentPartOfDay is not null || hasCurrentTemporalSignal))
{
resolvedDate = baseDate.Value.Date;
isDateDeterministic = true;
}
else if (context.LastDate.HasValue && !HasApproximateSignal(temporal, fallbackText))
{
resolvedDate = context.LastDate.Value.Date;
isDateDeterministic = true;
}
}
var mode = "Unknown";
DateTime? start = null;
if (resolvedDate.HasValue && isDateDeterministic)
{
if (exactTime.HasValue)
{
start = resolvedDate.Value.Date.Add(exactTime.Value);
mode = "Exact DateTime";
}
else
{
start = resolvedDate.Value.Date;
mode = "Exact Date";
}
}
else if (!string.IsNullOrWhiteSpace(relativeText))
{
mode = HasApproximateSignal(temporal, fallbackText) ? "Approximate" : "Relative";
}
var displayText = string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase)
? null
: BuildRelativeText(start, exactTime, displayPartOfDay, relativeText, temporal);
var resolvedConfidence = ResolveConfidenceName(confidence, isDateDeterministic, mode);
var nextContext = context with
{
LastDate = start?.Date ?? (isDateDeterministic ? resolvedDate : context.LastDate),
LastPartOfDay = displayPartOfDay ?? context.LastPartOfDay
};
return new StoryIntelligenceTemporalResolution(start, null, null, null, displayText, mode, resolvedConfidence, nextContext);
}
public static string AddRollingContextToPromptJson(string sceneContextJson, StoryIntelligenceTemporalContext context)
{
if (string.IsNullOrWhiteSpace(sceneContextJson))
{
return sceneContextJson;
}
using var document = JsonDocument.Parse(sceneContextJson);
var clone = JsonSerializer.Deserialize<Dictionary<string, object?>>(document.RootElement.GetRawText()) ?? [];
clone["rollingTemporalContext"] = new
{
chapterAnchorDate = context.ChapterAnchorDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
previousResolvedDate = context.LastDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
previousPartOfDay = context.LastPartOfDay
};
return JsonSerializer.Serialize(clone, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
private static SceneIntelligenceTemporalAnalysis? InferFromLegacyFields(SceneIntelligenceScene? scene)
{
var text = FirstNonBlank(scene?.Setting?.DateOrTimeReference, scene?.Setting?.TimeOfDay, scene?.TimelineClues?.FirstOrDefault()?.Clue);
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return new SceneIntelligenceTemporalAnalysis
{
Evidence = text,
PartOfDay = CleanPartOfDay(text),
Confidence = scene?.Setting?.Confidence ?? scene?.TimelineClues?.FirstOrDefault()?.Confidence
};
}
private static bool TryResolveRelativeDate(DateTime baseDate, SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText, out DateTime date, out bool exact)
{
date = baseDate.Date;
exact = false;
var text = Clean(FirstNonBlank(temporal?.Evidence, fallbackText, temporal?.RelativeTransition));
if (temporal?.TargetDayOfWeek is not null && DaysOfWeek.TryGetValue(temporal.TargetDayOfWeek, out var targetDay))
{
var dayOffset = ((int)targetDay - (int)baseDate.DayOfWeek + 7) % 7;
date = baseDate.Date.AddDays(dayOffset == 0 ? 7 : dayOffset);
exact = true;
return true;
}
if (temporal?.CrossesMidnight == true || Contains(text, "after midnight") || Contains(text, "past midnight"))
{
date = baseDate.Date.AddDays(1);
exact = true;
return true;
}
var amount = temporal?.RelativeAmount;
var unit = Clean(temporal?.RelativeUnit);
if (amount.HasValue && amount.Value != 0 && IsDayUnit(unit))
{
date = baseDate.Date.AddDays(amount.Value);
exact = true;
return true;
}
if (RegexMatch(text, @"\b(\d+)\s+days?\s+later\b", out var value) && int.TryParse(value, out var elapsedDays))
{
date = baseDate.Date.AddDays(elapsedDays);
exact = true;
return true;
}
if (Contains(text, "following morning") || Contains(text, "next morning"))
{
date = baseDate.Date.AddDays(1);
exact = true;
return true;
}
if (Contains(text, "later that") || Contains(text, "same day") || Contains(text, "that afternoon") || Contains(text, "that evening"))
{
date = baseDate.Date;
exact = true;
return true;
}
return false;
}
private static DateTime? ParseDate(string? value, DateTime? contextDate = null)
{
var clean = StripOrdinalSuffixes(Clean(value));
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
var hasExplicitYear = HasExplicitYear(clean);
foreach (var culture in DateCultures)
{
if (DateTime.TryParseExact(clean, DateFormats, culture, DateTimeStyles.AllowWhiteSpaces, out var exact))
{
return exact.Date;
}
if (hasExplicitYear && DateTime.TryParse(clean, culture, DateTimeStyles.AllowWhiteSpaces, out var parsed) && parsed.Year > 1000)
{
return parsed.Date;
}
}
var embeddedDate = System.Text.RegularExpressions.Regex.Match(
clean,
@"\b\d{1,2}\s+(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (embeddedDate.Success)
{
return ParseDate(embeddedDate.Value);
}
if (contextDate.HasValue)
{
var monthDay = System.Text.RegularExpressions.Regex.Match(
clean,
@"\b(?<day>\d{1,2})\s+(?<month>Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!monthDay.Success)
{
monthDay = System.Text.RegularExpressions.Regex.Match(
clean,
@"\b(?<month>Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+(?<day>\d{1,2})\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
if (monthDay.Success
&& int.TryParse(monthDay.Groups["day"].Value, out var day)
&& TryMonthNumber(monthDay.Groups["month"].Value, out var month)
&& day >= 1
&& day <= DateTime.DaysInMonth(contextDate.Value.Year, month))
{
return new DateTime(contextDate.Value.Year, month, day);
}
}
return null;
}
private static bool HasExplicitYear(string value)
=> System.Text.RegularExpressions.Regex.IsMatch(value, @"\b(?:1[5-9]\d{2}|20\d{2}|21\d{2})\b");
private static bool TryMonthNumber(string value, out int month)
{
month = 0;
foreach (var culture in DateCultures)
{
if (DateTime.TryParseExact(value, ["MMM", "MMMM"], culture, DateTimeStyles.AllowWhiteSpaces, out var parsed))
{
month = parsed.Month;
return true;
}
}
return false;
}
private static TimeSpan? ParseTime(string? value, string? partOfDay)
{
var clean = Clean(value)?.Replace('’', '\'');
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
var hasMeridiem = clean.Contains("am", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("pm", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("morning", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("afternoon", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("evening", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("night", StringComparison.OrdinalIgnoreCase);
if (!hasMeridiem && !clean.Contains(':') && string.IsNullOrWhiteSpace(partOfDay))
{
return null;
}
foreach (var pair in ClockWords)
{
if (clean.Contains($"{pair.Key} o'clock", StringComparison.OrdinalIgnoreCase)
|| clean.Contains($"{pair.Key} oclock", StringComparison.OrdinalIgnoreCase))
{
var clock = ApplyPartOfDayToClockHour(pair.Value, partOfDay ?? clean);
if (clock.HasValue)
{
return clock;
}
}
}
if (RegexMatch(clean, @"\b(\d{1,2})\s*o'?clock\b", out var hourText) && int.TryParse(hourText, out var hour))
{
var clock = ApplyPartOfDayToClockHour(hour, partOfDay ?? clean);
if (clock.HasValue)
{
return clock;
}
}
if (RegexMatch(clean, @"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s*o'?clock\b", out hourText)
&& ClockWords.TryGetValue(hourText, out hour))
{
var clock = ApplyPartOfDayToClockHour(hour, partOfDay ?? clean);
if (clock.HasValue)
{
return clock;
}
}
if (TryParseWordClock(clean, partOfDay, out var wordClock))
{
return wordClock;
}
if (DateTime.TryParse(clean, CultureInfo.InvariantCulture, DateTimeStyles.NoCurrentDateDefault, out var parsed))
{
return parsed.TimeOfDay;
}
return null;
}
private static bool TryParseWordClock(string text, string? partOfDay, out TimeSpan time)
{
time = default;
var match = System.Text.RegularExpressions.Regex.Match(
text,
@"\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)[-\s]+((?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty)(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?)\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!match.Success || !ClockWords.TryGetValue(match.Groups[1].Value, out var hour) || !TryParseMinuteWords(match.Groups[2].Value, out var minute))
{
return false;
}
var clock = ApplyPartOfDayToClockHour(hour, partOfDay);
if (!clock.HasValue)
{
return false;
}
time = clock.Value.Add(TimeSpan.FromMinutes(minute));
return true;
}
private static bool TryParseMinuteWords(string value, out int minute)
{
minute = 0;
var parts = value.Split(['-', ' '], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || parts.Length > 2)
{
return false;
}
if (!MinuteWords.TryGetValue(parts[0], out minute))
{
return false;
}
if (parts.Length == 2)
{
if (!MinuteWords.TryGetValue(parts[1], out var ones) || ones >= 10)
{
return false;
}
minute += ones;
}
return minute is >= 0 and < 60;
}
private static TimeSpan? ApplyPartOfDayToClockHour(int hour, string? partOfDay)
{
var lowerPart = Clean(partOfDay);
if (Contains(lowerPart, "evening") || Contains(lowerPart, "night"))
{
hour = hour == 12 ? 12 : hour + 12;
return TimeSpan.FromHours(hour);
}
if (Contains(lowerPart, "morning"))
{
return TimeSpan.FromHours(hour == 12 ? 0 : hour);
}
return null;
}
private static string? BuildRelativeText(DateTime? start, TimeSpan? exactTime, string? partOfDay, string? relativeText, SceneIntelligenceTemporalAnalysis? temporal)
{
var cleanRelative = Clean(FirstNonBlank(relativeText, temporal?.Evidence, temporal?.UnresolvedReason));
if (start.HasValue && exactTime is null)
{
return string.IsNullOrWhiteSpace(partOfDay)
? null
: $"{start.Value:dd MMM yyyy}, {partOfDay}";
}
if (!string.IsNullOrWhiteSpace(cleanRelative) && !start.HasValue)
{
return cleanRelative;
}
return null;
}
private static string ResolveConfidenceName(decimal confidence, bool exactDate, string mode)
{
if (string.Equals(mode, "Unknown", StringComparison.OrdinalIgnoreCase))
{
return "Unknown";
}
if (confidence >= 0.9m && exactDate)
{
return "Confirmed";
}
return exactDate ? "Implied" : "Estimated";
}
private static decimal NormaliseConfidence(decimal? confidence)
=> confidence is >= 0 and <= 1 ? confidence.Value : 0.0m;
private static bool IsSameDayReference(SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText)
=> Contains(Clean(FirstNonBlank(temporal?.RelativeTransition, temporal?.Evidence, fallbackText)), "same")
|| Contains(Clean(FirstNonBlank(temporal?.Evidence, fallbackText)), "later that");
private static bool HasApproximateSignal(SceneIntelligenceTemporalAnalysis? temporal, string? fallbackText)
=> Contains(Clean(temporal?.UnresolvedReason), "few")
|| Contains(Clean(temporal?.Evidence), "few")
|| Contains(Clean(fallbackText), "few")
|| Contains(Clean(temporal?.UnresolvedReason), "approx")
|| Contains(Clean(temporal?.Evidence), "approx")
|| Contains(Clean(fallbackText), "approx");
private static bool HasSceneTemporalSignal(SceneIntelligenceTemporalAnalysis? temporal, SceneIntelligenceScene? scene)
=> temporal is not null
&& (!string.IsNullOrWhiteSpace(temporal.AbsoluteDate)
|| !string.IsNullOrWhiteSpace(temporal.ExactTime)
|| !string.IsNullOrWhiteSpace(temporal.Evidence)
|| !string.IsNullOrWhiteSpace(scene?.Setting?.DateOrTimeReference)
|| !string.IsNullOrWhiteSpace(scene?.Setting?.TimeOfDay));
private static bool IsDayUnit(string? unit)
=> string.Equals(unit, "day", StringComparison.OrdinalIgnoreCase)
|| string.Equals(unit, "days", StringComparison.OrdinalIgnoreCase);
private static string? CleanPartOfDay(string? value)
{
var text = Clean(value);
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
foreach (var part in new[] { "morning", "afternoon", "evening", "night", "dawn", "dusk", "midday", "midnight" })
{
if (text.Contains(part, StringComparison.OrdinalIgnoreCase))
{
return part;
}
}
return null;
}
private static bool Contains(string? text, string value)
=> !string.IsNullOrWhiteSpace(text) && text.Contains(value, StringComparison.OrdinalIgnoreCase);
private static bool RegexMatch(string? text, string pattern, out string value)
{
value = string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var match = System.Text.RegularExpressions.Regex.Match(text, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (!match.Success)
{
return false;
}
value = match.Groups[1].Value;
return true;
}
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static string StripOrdinalSuffixes(string value)
=> System.Text.RegularExpressions.Regex.Replace(value, @"\b(\d{1,2})(st|nd|rd|th)\b", "$1", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
private static string? FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
}
public sealed record StoryIntelligenceTemporalContext(DateTime? ChapterAnchorDate, DateTime? LastDate, string? LastPartOfDay);
public sealed record StoryIntelligenceTemporalResolution(
DateTime? StartDateTime,
DateTime? EndDateTime,
decimal? DurationAmount,
int? DurationUnitID,
string? RelativeTimeText,
string TimeModeName,
string TimeConfidenceName,
StoryIntelligenceTemporalContext NextContext);

View File

@ -1,133 +0,0 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public sealed class StoryPointInputs
{
public TimelineData Timeline { get; init; } = new();
public IReadOnlyList<Character> Characters { get; init; } = [];
public IReadOnlyList<SceneCharacter> Appearances { get; init; } = [];
public IReadOnlyList<StoryAsset> Assets { get; init; } = [];
public IReadOnlyList<AssetEvent> AssetEvents { get; init; } = [];
public IReadOnlyList<LocationItem> Locations { get; init; } = [];
public RelationshipMapData Relationships { get; init; } = new();
public StoryPointHistory History { get; init; } = new();
public IReadOnlyList<ContinuityWarningAcknowledgement> Acknowledgements { get; init; } = [];
}
public interface IStoryPointContextService
{
Task<StoryPointViewModel?> GetAsync(int projectId, int? sceneId);
}
public sealed class StoryPointContextService(IProjectAccessService access, ITimelineRepository timelineRepository,
ICharacterRepository characters, IAssetRepository assets, ILocationRepository locations,
IStoryPointRepository history, IContinuityWarningAcknowledgementRepository acknowledgements) : IStoryPointContextService
{
public async Task<StoryPointViewModel?> GetAsync(int projectId, int? sceneId)
{
if (!await access.CanAccessProjectAsync(projectId)) return null;
var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false);
if (timeline.Project is null) return null;
var ordered = StoryStateService.OrderScenes(timeline).ToList();
if (sceneId.HasValue && ordered.All(x => x.SceneID != sceneId)) return null;
sceneId ??= ordered.FirstOrDefault()?.SceneID;
StoryPointSnapshot? snapshot = null;
if (sceneId.HasValue)
{
// Each repository uses its own connection; batch count is independent of scene/entity count.
var peopleTask = characters.GetTimelineAsync(projectId, null);
var assetsTask = assets.GetTimelineAsync(projectId, null);
var locationsTask = locations.ListByProjectAsync(projectId);
var relationshipsTask = characters.GetRelationshipMapAsync(projectId);
var historyTask = history.GetHistoryAsync(projectId, sceneId.Value);
var acknowledgementsTask = acknowledgements.ListByProjectAsync(projectId);
await Task.WhenAll(peopleTask, assetsTask, locationsTask, relationshipsTask, historyTask, acknowledgementsTask);
snapshot = Compose(new StoryPointInputs
{
Timeline = timeline, Characters = peopleTask.Result.Characters, Appearances = peopleTask.Result.Appearances,
Assets = assetsTask.Result.Assets, AssetEvents = assetsTask.Result.Events,
Locations = locationsTask.Result, Relationships = relationshipsTask.Result,
History = historyTask.Result, Acknowledgements = acknowledgementsTask.Result
}, sceneId.Value);
}
return new StoryPointViewModel { Project = timeline.Project, Books = timeline.Books.OrderBy(x => x.SortOrder).ThenBy(x => x.BookNumber).ToList(),
Chapters = timeline.Chapters.OrderBy(x => x.SortOrder).ThenBy(x => x.ChapterNumber).ToList(), Scenes = ordered, Snapshot = snapshot };
}
public static StoryPointSnapshot? Compose(StoryPointInputs input, int sceneId)
{
var ordered = StoryStateService.OrderScenes(input.Timeline).ToList();
var maximum = ordered.FindIndex(x => x.SceneID == sceneId);
if (maximum < 0 || input.Timeline.Project is null) return null;
var scene = ordered[maximum];
var chapter = input.Timeline.Chapters.Single(x => x.ChapterID == scene.ChapterID);
var book = input.Timeline.Books.Single(x => x.BookID == chapter.BookID);
var indexes = ordered.Select((x, i) => (x.SceneID, i)).ToDictionary(x => x.SceneID, x => x.i);
var allowed = ordered.Take(maximum + 1).Select(x => x.SceneID).ToHashSet();
var chaptersById = input.Timeline.Chapters.ToDictionary(x => x.ChapterID);
var booksById = input.Timeline.Books.ToDictionary(x => x.BookID);
var sources = ordered.Take(maximum + 1).ToDictionary(x => x.SceneID, x =>
{
var ch = chaptersById[x.ChapterID];
return new StoryPointSource(x.SceneID, $"{booksById[ch.BookID].BookDisplayTitle} · Ch {ch.ChapterNumber:0.##} · Scene {x.SceneNumber:0.##}");
});
var present = input.Appearances.Where(x => x.SceneID == sceneId).GroupBy(x => x.CharacterID)
.Select(x => x.OrderByDescending(y => y.UpdatedDate).First()).OrderBy(x => x.CharacterName).ToList();
var presentIds = present.Select(x => x.CharacterID).ToHashSet();
var state = StoryStateService.BuildAtScene(input, sceneId);
var knowledge = StoryStateService.KnowledgeAtScene(input.History.Knowledge, indexes, sceneId).ToLookup(x => x.CharacterID);
var previous = input.Appearances.Where(x => allowed.Contains(x.SceneID) && x.SceneID != sceneId)
.GroupBy(x => x.CharacterID).ToDictionary(x => x.Key, x => x.OrderBy(y => indexes[y.SceneID]).Last().SceneID);
var characterModels = input.Characters.ToDictionary(x => x.CharacterID);
var people = present.Select(x => new StoryPointCharacter(x,
characterModels.TryGetValue(x.CharacterID, out var character)
? CharacterAgeDisplayService.GetAgeForScene(character, scene.TimeModeName, scene.StartDateTime)
: CharacterAgeDisplayService.GetAgeForScene(x, scene.TimeModeName, scene.StartDateTime),
state.Characters.FirstOrDefault(y => y.CharacterID == x.CharacterID),
previous.TryGetValue(x.CharacterID, out var previousId) ? sources[previousId] : null, knowledge[x.CharacterID].ToList())).ToList();
var custodyIds = input.History.Custodians.Where(x => x.CharacterID.HasValue && presentIds.Contains(x.CharacterID.Value))
.Select(x => x.AssetCustodyEventID).ToHashSet();
var pointAssets = state.Assets.Where(x => x.LastConfirmedSceneID == sceneId
|| (scene.PrimaryLocationID.HasValue && x.LocationID == scene.PrimaryLocationID)
|| (x.CustodyEventID.HasValue && custodyIds.Contains(x.CustodyEventID.Value))).ToList();
var relevantCharacters = presentIds.Concat(state.Characters.Where(x => scene.PrimaryLocationID.HasValue && x.LocationID == scene.PrimaryLocationID).Select(x => x.CharacterID)).ToHashSet();
var location = scene.PrimaryLocationID.HasValue
? state.Locations.FirstOrDefault(x => x.LocationID == scene.PrimaryLocationID) ?? input.Locations.Where(x => x.LocationID == scene.PrimaryLocationID)
.Select(x => new LocationStoryStateViewModel { LocationID = x.LocationID, LocationName = x.LocationName, LocationPath = x.LocationPath }).FirstOrDefault()
: null;
var lastLocationScene = ordered.Take(maximum).LastOrDefault(x => scene.PrimaryLocationID.HasValue && x.PrimaryLocationID == scene.PrimaryLocationID);
return new StoryPointSnapshot
{
ProjectID = input.Timeline.Project.ProjectID, Book = book, Chapter = chapter, Scene = scene, Characters = people,
PovName = scene.POVCharacterID.HasValue ? characterModels.GetValueOrDefault(scene.POVCharacterID.Value)?.CharacterName ?? scene.POVCharacterName : scene.POVCharacterName,
Relationships = RelationshipMapService.BuildAtScene(input.Relationships, indexes, sceneId)
.Where(x => presentIds.Contains(x.CharacterAID) || presentIds.Contains(x.CharacterBID)).ToList(),
Assets = pointAssets, Location = location, PreviousLocationUse = lastLocationScene is null ? null : sources[lastLocationScene.SceneID],
Threads = BuildThreads(input.History, allowed, indexes, sources),
Warnings = StoryStateService.WarningsAtScene(input, sceneId, relevantCharacters, pointAssets.Select(x => x.StoryAssetID).ToHashSet()), Sources = sources
};
}
private static IReadOnlyList<StoryPointThread> BuildThreads(StoryPointHistory history, HashSet<int> allowed,
IReadOnlyDictionary<int, int> indexes, IReadOnlyDictionary<int, StoryPointSource> sources)
{
var events = history.ThreadEvents.Where(x => allowed.Contains(x.SceneID)).ToLookup(x => x.PlotThreadID);
var result = new List<StoryPointThread>();
foreach (var thread in history.Threads.Where(x => !x.IsArchived))
{
var threadEvents = events[thread.PlotThreadID].OrderBy(x => indexes[x.SceneID]).ThenBy(x => x.ThreadEventID).ToList();
if (thread.IntroducedSceneID.HasValue ? !allowed.Contains(thread.IntroducedSceneID.Value) : threadEvents.Count == 0) continue;
if (thread.ActualResolutionSceneID.HasValue && allowed.Contains(thread.ActualResolutionSceneID.Value)) continue;
var latestLifecycle = threadEvents.LastOrDefault(x => x.EventTypeName is "Resolved" or "Payoff" or "Introduced");
if (latestLifecycle?.EventTypeName is "Resolved" or "Payoff") continue;
var last = threadEvents.LastOrDefault();
var sourceId = last?.SceneID ?? thread.IntroducedSceneID;
result.Add(new StoryPointThread(thread.PlotThreadID, thread.ThreadTitle, thread.ThreadTypeName, thread.PlotLineName,
sourceId.HasValue ? sources.GetValueOrDefault(sourceId.Value) : null, last?.EventTypeName ?? "Introduced"));
}
return result.OrderBy(x => x.Title).ToList();
}
}

View File

@ -17,9 +17,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
private static readonly HashSet<string> ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal");
private static readonly HashSet<string> EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown");
private static readonly HashSet<string> RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation");
private static readonly HashSet<string> TemporalTransitions = CreateSet("same", "later", "next", "following", "after", "before", "elapsed", "unknown");
private static readonly HashSet<string> TemporalUnits = CreateSet("minute", "hour", "day", "week", "month", "year", "unknown");
private static readonly HashSet<string> DaysOfWeek = CreateSet("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday");
public ValidationResult Validate(SceneIntelligenceScene? scene)
{
@ -45,7 +42,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
ValidateRelationships(scene.Relationships, result);
ValidateKnowledgeChanges(scene.KnowledgeChanges, result);
ValidateTimelineClues(scene.TimelineClues, result);
ValidateTemporalAnalysis(scene.TemporalAnalysis, result);
ValidateNarrativeArcs(scene.NarrativeArcs, result);
ValidateQuestionsRaised(scene.QuestionsRaised, result);
ValidateQuestionsAnswered(scene.QuestionsAnswered, result);
@ -85,7 +81,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
RequiredArray(scene.Relationships, "relationships", result);
RequiredArray(scene.KnowledgeChanges, "knowledgeChanges", result);
RequiredArray(scene.TimelineClues, "timelineClues", result);
RequiredObject(scene.TemporalAnalysis, "temporalAnalysis", result);
RequiredArray(scene.NarrativeArcs, "narrativeArcs", result);
RequiredArray(scene.QuestionsRaised, "questionsRaised", result);
RequiredArray(scene.QuestionsAnswered, "questionsAnswered", result);
@ -277,19 +272,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
AddWarning(result, $"{path}.relationshipSignal", "Relationship signal is outside the preferred V1 neutral terms.", "Use a supported neutral signal when one fits.");
}
if (item.ProposalKind is not null)
{
if (item.ProposalKind is not ("Factual" or "State" or "Development"))
AddError(result, $"{path}.proposalKind", "Unknown relationship proposal kind.", "Use Factual, State or Development.");
RequiredString(item.Development, $"{path}.development", result);
RequiredString(item.RelationshipState, $"{path}.relationshipState", result);
if (item.ProposalKind == "Factual") RequiredString(item.FactualRelationshipType, $"{path}.factualRelationshipType", result);
if (item.IsReciprocal is null || item.IsKnownToOtherCharacter is null)
AddError(result, path, "Relationship direction and awareness are required.", "Supply isReciprocal and isKnownToOtherCharacter from scene evidence.");
if (item.Intensity is < 1 or > 10)
AddError(result, $"{path}.intensity", "Intensity must be between 1 and 10 or null.", "Use emotional strength, not confidence.");
}
RequiredString(item.Evidence, $"{path}.evidence", result);
Confidence(item.Confidence, $"{path}.confidence", result);
var key = string.Join("|", new[] { item.CharacterA, item.CharacterB, item.RelationshipSignal }.Select(value => value?.Trim().ToLowerInvariant()));
@ -344,27 +326,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
}
}
private static void ValidateTemporalAnalysis(SceneIntelligenceTemporalAnalysis? value, ValidationResult result)
{
if (value is null)
{
return;
}
RequiredScalar(value.AbsoluteDate, "temporalAnalysis.absoluteDate", result, allowNull: true);
RequiredScalar(value.ExactTime, "temporalAnalysis.exactTime", result, allowNull: true);
RequiredScalar(value.PartOfDay, "temporalAnalysis.partOfDay", result, allowNull: true);
NullableEnum(value.RelativeTransition, "temporalAnalysis.relativeTransition", TemporalTransitions, result);
RequiredScalar(value.RelativeAmount, "temporalAnalysis.relativeAmount", result, allowNull: true);
NullableEnum(value.RelativeUnit, "temporalAnalysis.relativeUnit", TemporalUnits, result);
NullableEnum(value.TargetDayOfWeek, "temporalAnalysis.targetDayOfWeek", DaysOfWeek, result);
RequiredScalar(value.CrossesMidnight, "temporalAnalysis.crossesMidnight", result, allowNull: true);
RequiredScalar(value.Evidence, "temporalAnalysis.evidence", result, allowNull: true);
Confidence(value.Confidence, "temporalAnalysis.confidence", result);
RequiredScalar(value.UnresolvedReason, "temporalAnalysis.unresolvedReason", result, allowNull: true);
Unknown(value.ExtensionData, "temporalAnalysis", result);
}
private static void ValidateQuestionsRaised(List<SceneIntelligenceQuestionRaised>? values, ValidationResult result)
{
if (values is null)

View File

@ -1,65 +0,0 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
// Read projections shared with Continuity Explorer, without its per-entity database adapters.
public sealed partial class StoryStateService
{
public static StoryStateViewModel BuildAtScene(StoryPointInputs input, int sceneId)
{
var ordered = OrderScenes(input.Timeline).ToList();
var indexes = BuildSceneIndexes(ordered);
if (!indexes.TryGetValue(sceneId, out var maximum)) return new();
var allowed = ordered.Take(maximum + 1).Select(x => x.SceneID).ToHashSet();
var state = new StoryStateViewModel
{
Characters = BuildCharacterState(input.Characters, input.Appearances, sceneId, allowed, indexes),
Assets = BuildAssetState(input.Assets, input.AssetEvents, input.History.AssetLocations, input.History.Custody,
sceneId, allowed, indexes, ordered.ToDictionary(x => x.SceneID))
};
state.Locations = BuildLocationState(input.Locations, state);
return state;
}
public static IReadOnlyList<CharacterKnowledgeItem> KnowledgeAtScene(
IEnumerable<CharacterKnowledgeItem> items, IReadOnlyDictionary<int, int> indexes, int sceneId)
{
if (!indexes.TryGetValue(sceneId, out var maximum)) return [];
return items.Where(x => indexes.TryGetValue(x.SceneID, out var index) && index <= maximum)
.GroupBy(x => (x.CharacterID, Identity: KnowledgeIdentity(x)))
.Select(x => x.OrderBy(y => indexes[y.SceneID]).ThenBy(y => y.CreatedDate).ThenBy(y => y.CharacterKnowledgeID).Last())
.ToList();
}
public static IReadOnlyList<ContinuityWarningViewModel> WarningsAtScene(
StoryPointInputs input, int sceneId, IReadOnlySet<int> characterIds, IReadOnlySet<int> assetIds)
{
var ordered = OrderScenes(input.Timeline).ToList();
var maximum = ordered.FindIndex(x => x.SceneID == sceneId);
if (maximum < 0) return [];
// Bound inputs before invoking any warning algorithm: later evidence must not suppress earlier warnings.
ordered = ordered.Take(maximum + 1).ToList();
var allowed = ordered.Select(x => x.SceneID).ToHashSet();
var context = new RangeContext(ordered, ordered, allowed, BuildSceneIndexes(ordered),
ordered.ToDictionary(x => x.SceneID, x => SceneLabel(x.SceneNumber, x.SceneTitle)), BuildSceneContexts(input.Timeline));
var appearances = input.Appearances.Where(x => allowed.Contains(x.SceneID)).ToList();
var locations = input.History.AssetLocations.Where(x => allowed.Contains(x.SceneID)).ToList();
var custodians = input.History.Custodians.Where(x => allowed.Contains(x.SceneID)).ToList();
var warnings = new List<ContinuityWarningViewModel>();
warnings.AddRange(BuildMultipleCharacterLocationWarnings(appearances, characterIds, context));
warnings.AddRange(BuildImpossibleMovementWarnings(appearances, characterIds, context));
warnings.AddRange(BuildUnknownCharacterLocationWarnings(input.Characters, appearances, characterIds, context));
warnings.AddRange(BuildKnowledgeRegressionWarnings(input.Characters, characterIds, context,
input.History.Knowledge.Where(x => allowed.Contains(x.SceneID)).ToList()));
warnings.AddRange(BuildAssetLocationConflictWarnings(input.Assets, locations, custodians, assetIds, context));
warnings.AddRange(BuildUnknownAssetLocationWarnings(input.Assets, input.AssetEvents.Where(x => allowed.Contains(x.SceneID)).ToList(),
locations, custodians, assetIds, context));
ApplyAcknowledgements(warnings, input.Acknowledgements);
return warnings.Where(x => !x.IsAcknowledged && x.SceneID.HasValue && allowed.Contains(x.SceneID.Value)
&& (x.SceneID == sceneId || (x.CharacterID.HasValue && characterIds.Contains(x.CharacterID.Value))
|| (x.AssetID.HasValue && assetIds.Contains(x.AssetID.Value))))
.OrderBy(x => WarningSeveritySort(x.Severity)).ThenBy(x => x.Message).ToList();
}
}

View File

@ -1,465 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterEnrichmentRuns
(
CharacterEnrichmentRunID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentRuns PRIMARY KEY,
ProjectID int NOT NULL,
BookID int NOT NULL,
UserID int NOT NULL,
StoryIntelligenceBookPipelineID int NULL,
CharacterResolutionVersion nvarchar(120) NOT NULL,
Status nvarchar(30) NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_Status DEFAULT N'Queued',
TotalCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_TotalCharacters DEFAULT 0,
ProcessedCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_ProcessedCharacters DEFAULT 0,
CurrentStage nvarchar(120) NULL,
CurrentMessage nvarchar(300) NULL,
QueuedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_QueuedUtc DEFAULT SYSUTCDATETIME(),
StartedUtc datetime2 NULL,
CompletedUtc datetime2 NULL,
FailedUtc datetime2 NULL,
AttemptCount int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_AttemptCount DEFAULT 0,
Model nvarchar(100) NULL,
InputTokens int NULL,
OutputTokens int NULL,
TotalTokens int NULL,
DurationMs bigint NULL,
ErrorMessage nvarchar(max) NULL,
ErrorDetail nvarchar(max) NULL,
IsCurrent bit NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_IsCurrent DEFAULT 1,
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CharacterEnrichmentRuns_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_CharacterEnrichmentRuns_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE,
CONSTRAINT FK_CharacterEnrichmentRuns_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
CONSTRAINT FK_CharacterEnrichmentRuns_Pipeline FOREIGN KEY (StoryIntelligenceBookPipelineID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentRuns_Queue' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
CREATE INDEX IX_CharacterEnrichmentRuns_Queue ON dbo.CharacterEnrichmentRuns(Status, QueuedUtc) INCLUDE (BookID, UserID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterEnrichmentRuns_CurrentVersion' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
CREATE UNIQUE INDEX UX_CharacterEnrichmentRuns_CurrentVersion
ON dbo.CharacterEnrichmentRuns(BookID, CharacterResolutionVersion)
WHERE IsCurrent = 1 AND Status IN (N'Queued', N'Running', N'Completed');
GO
IF OBJECT_ID(N'dbo.CharacterEnrichmentEvidence', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterEnrichmentEvidence
(
CharacterEnrichmentEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentEvidence PRIMARY KEY,
CharacterEnrichmentRunID int NOT NULL,
CharacterID int NOT NULL,
SceneID int NULL,
FieldName nvarchar(80) NOT NULL,
Value nvarchar(500) NULL,
Evidence nvarchar(700) NULL,
Confidence decimal(5,4) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Confidence DEFAULT 0,
Source nvarchar(80) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Source DEFAULT N'Character enrichment',
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CharacterEnrichmentEvidence_Run FOREIGN KEY (CharacterEnrichmentRunID) REFERENCES dbo.CharacterEnrichmentRuns(CharacterEnrichmentRunID) ON DELETE CASCADE,
CONSTRAINT FK_CharacterEnrichmentEvidence_Character FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
CONSTRAINT FK_CharacterEnrichmentEvidence_Scene FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentEvidence_Character' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentEvidence'))
CREATE INDEX IX_CharacterEnrichmentEvidence_Character ON dbo.CharacterEnrichmentEvidence(CharacterID, CharacterEnrichmentRunID, FieldName);
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Queue
@ProjectID int,
@BookID int,
@UserID int,
@StoryIntelligenceBookPipelineID int = NULL,
@CharacterResolutionVersion nvarchar(120)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ExistingID int, @TotalCharacters int;
SELECT @TotalCharacters = COUNT(DISTINCT c.CharacterID)
FROM dbo.Characters c
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND ch.BookID = @BookID;
SELECT TOP (1) @ExistingID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID
AND CharacterResolutionVersion = @CharacterResolutionVersion
AND IsCurrent = 1
AND Status IN (N'Queued', N'Running', N'Completed')
ORDER BY CharacterEnrichmentRunID DESC;
IF @ExistingID IS NULL
BEGIN
INSERT dbo.CharacterEnrichmentRuns
(ProjectID, BookID, UserID, StoryIntelligenceBookPipelineID, CharacterResolutionVersion, Status, TotalCharacters, CurrentStage, CurrentMessage)
VALUES
(@ProjectID, @BookID, @UserID, @StoryIntelligenceBookPipelineID, @CharacterResolutionVersion, N'Queued', @TotalCharacters, N'Preparing character analysis', N'Preparing character analysis...');
SET @ExistingID = CAST(SCOPE_IDENTITY() AS int);
END;
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @ExistingID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Retry
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PreviousID int, @ProjectID int, @PipelineID int, @Version nvarchar(120);
SELECT TOP (1)
@PreviousID = CharacterEnrichmentRunID,
@ProjectID = ProjectID,
@PipelineID = StoryIntelligenceBookPipelineID,
@Version = CharacterResolutionVersion
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND UserID = @UserID AND IsCurrent = 1 AND Status = N'Failed'
ORDER BY CharacterEnrichmentRunID DESC;
IF @PreviousID IS NULL
BEGIN
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET IsCurrent = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @PreviousID;
EXEC dbo.CharacterEnrichmentRun_Queue
@ProjectID = @ProjectID,
@BookID = @BookID,
@UserID = @UserID,
@StoryIntelligenceBookPipelineID = @PipelineID,
@CharacterResolutionVersion = @Version;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_ClaimNext
@LeaseMinutes int = 90
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RunID int;
SELECT TOP (1) @RunID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, READPAST)
WHERE IsCurrent = 1
AND (
Status = N'Queued'
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
)
ORDER BY QueuedUtc, CharacterEnrichmentRunID;
IF @RunID IS NULL
BEGIN
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Running',
StartedUtc = COALESCE(StartedUtc, SYSUTCDATETIME()),
AttemptCount = AttemptCount + 1,
CurrentStage = N'Preparing character analysis',
CurrentMessage = N'Preparing character analysis...',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @RunID;
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @RunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) *
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND IsCurrent = 1
ORDER BY CharacterEnrichmentRunID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_UpdateProgress
@CharacterEnrichmentRunID int,
@TotalCharacters int,
@ProcessedCharacters int,
@CurrentStage nvarchar(120),
@CurrentMessage nvarchar(300)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CharacterEnrichmentRuns
SET TotalCharacters = @TotalCharacters,
ProcessedCharacters = @ProcessedCharacters,
CurrentStage = @CurrentStage,
CurrentMessage = @CurrentMessage,
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichment_ContextGet
@ProjectID int,
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
BookTitle = CASE WHEN NULLIF(LTRIM(RTRIM(b.Subtitle)), N'') IS NULL THEN b.BookTitle ELSE CONCAT(b.BookTitle, N': ', b.Subtitle) END,
b.StoryEra,
SeriesStartDate = CAST(NULL AS date)
FROM dbo.Books b
WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0;
SELECT DISTINCT
c.CharacterID,
c.CharacterName,
c.BirthDate,
c.AgeAtSeriesStart,
c.Height,
c.EyeColour,
c.DefaultDescription
FROM dbo.Characters c
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND ch.BookID = @BookID
ORDER BY c.CharacterName;
SELECT alias.CharacterID, alias.Alias
FROM dbo.CharacterAliases alias
INNER JOIN dbo.Characters c ON c.CharacterID = alias.CharacterID
WHERE c.ProjectID = @ProjectID AND c.IsArchived = 0
ORDER BY alias.CharacterID, alias.SortOrder, alias.Alias;
SELECT
sc.CharacterID,
s.SceneID,
ch.ChapterID,
ch.ChapterNumber,
ch.ChapterTitle,
s.SceneNumber,
s.SceneTitle,
sms.SourceText
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID AND c.ProjectID = @ProjectID AND c.IsArchived = 0
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.BookID = @BookID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
ORDER BY sc.CharacterID, ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Complete
@CharacterEnrichmentRunID int,
@Model nvarchar(100),
@InputTokens int = NULL,
@OutputTokens int = NULL,
@TotalTokens int = NULL,
@DurationMs bigint = NULL,
@ResultsJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ProjectID int, @BookID int, @PipelineID int;
SELECT @ProjectID = ProjectID, @BookID = BookID, @PipelineID = StoryIntelligenceBookPipelineID
FROM dbo.CharacterEnrichmentRuns
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
DECLARE @Results TABLE
(
CharacterID int NOT NULL PRIMARY KEY,
Summary nvarchar(max) NULL,
Confidence decimal(5,4) NOT NULL,
DateOfBirthValue nvarchar(120) NULL,
DateOfBirthConfidence decimal(5,4) NULL,
DateOfBirthEvidence nvarchar(700) NULL,
DateOfBirthSceneID int NULL,
AgeValue nvarchar(120) NULL,
AgeConfidence decimal(5,4) NULL,
AgeEvidence nvarchar(700) NULL,
AgeSceneID int NULL,
HeightValue nvarchar(120) NULL,
HeightConfidence decimal(5,4) NULL,
HeightEvidence nvarchar(700) NULL,
HeightSceneID int NULL,
EyeColourValue nvarchar(120) NULL,
EyeColourConfidence decimal(5,4) NULL,
EyeColourEvidence nvarchar(700) NULL,
EyeColourSceneID int NULL
);
INSERT @Results
SELECT
CharacterID,
NULLIF(LTRIM(RTRIM(Summary)), N''),
Confidence,
NULLIF(LTRIM(RTRIM(DateOfBirthValue)), N''),
DateOfBirthConfidence,
NULLIF(LTRIM(RTRIM(DateOfBirthEvidence)), N''),
DateOfBirthSceneID,
NULLIF(LTRIM(RTRIM(AgeValue)), N''),
AgeConfidence,
NULLIF(LTRIM(RTRIM(AgeEvidence)), N''),
AgeSceneID,
NULLIF(LTRIM(RTRIM(HeightValue)), N''),
HeightConfidence,
NULLIF(LTRIM(RTRIM(HeightEvidence)), N''),
HeightSceneID,
NULLIF(LTRIM(RTRIM(EyeColourValue)), N''),
EyeColourConfidence,
NULLIF(LTRIM(RTRIM(EyeColourEvidence)), N''),
EyeColourSceneID
FROM OPENJSON(@ResultsJson)
WITH
(
CharacterID int '$.characterID',
Summary nvarchar(max) '$.summary',
Confidence decimal(5,4) '$.confidence',
DateOfBirthValue nvarchar(120) '$.dateOfBirth.value',
DateOfBirthConfidence decimal(5,4) '$.dateOfBirth.confidence',
DateOfBirthEvidence nvarchar(700) '$.dateOfBirth.evidence',
DateOfBirthSceneID int '$.dateOfBirth.sceneID',
AgeValue nvarchar(120) '$.ageAtStartOfSeries.value',
AgeConfidence decimal(5,4) '$.ageAtStartOfSeries.confidence',
AgeEvidence nvarchar(700) '$.ageAtStartOfSeries.evidence',
AgeSceneID int '$.ageAtStartOfSeries.sceneID',
HeightValue nvarchar(120) '$.height.value',
HeightConfidence decimal(5,4) '$.height.confidence',
HeightEvidence nvarchar(700) '$.height.evidence',
HeightSceneID int '$.height.sceneID',
EyeColourValue nvarchar(120) '$.eyeColour.value',
EyeColourConfidence decimal(5,4) '$.eyeColour.confidence',
EyeColourEvidence nvarchar(700) '$.eyeColour.evidence',
EyeColourSceneID int '$.eyeColour.sceneID'
)
WHERE CharacterID IS NOT NULL;
UPDATE c
SET DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL THEN r.Summary ELSE c.DefaultDescription END,
BirthDate = CASE WHEN c.BirthDate IS NULL THEN TRY_CONVERT(date, r.DateOfBirthValue) ELSE c.BirthDate END,
AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL THEN TRY_CONVERT(int, r.AgeValue) ELSE c.AgeAtSeriesStart END,
Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL THEN r.HeightValue ELSE c.Height END,
EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL THEN r.EyeColourValue ELSE c.EyeColour END,
UpdatedDate = SYSUTCDATETIME()
FROM dbo.Characters c
INNER JOIN @Results r ON r.CharacterID = c.CharacterID
WHERE c.ProjectID = @ProjectID;
MERGE dbo.CharacterIntelligenceProfiles WITH (HOLDLOCK) AS target
USING
(
SELECT
ImportSessionID = @PipelineID,
ProjectID = @ProjectID,
BookID = @BookID,
CanonicalIdentityKey = CONCAT(N'character-enrichment-', r.CharacterID),
CanonicalName = c.CharacterName,
ProfileJson = (SELECT r.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
Confidence = r.Confidence,
NarrativeImportance = CAST(NULL AS nvarchar(60))
FROM @Results r
INNER JOIN dbo.Characters c ON c.CharacterID = r.CharacterID
WHERE @PipelineID IS NOT NULL
) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.CanonicalIdentityKey = source.CanonicalIdentityKey
WHEN MATCHED THEN
UPDATE SET CanonicalName = source.CanonicalName,
ProfileJson = source.ProfileJson,
Confidence = source.Confidence,
EvidenceVersion = N'CharacterEnrichmentV1',
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, CanonicalName, ProfileJson, Confidence, NarrativeImportance, IsRecurring, EvidenceVersion, LastUpdatedSceneResultID)
VALUES (source.ImportSessionID, source.ProjectID, source.BookID, source.CanonicalIdentityKey, source.CanonicalName, source.ProfileJson, source.Confidence, source.NarrativeImportance, 1, N'CharacterEnrichmentV1', NULL);
INSERT dbo.CharacterEnrichmentEvidence (CharacterEnrichmentRunID, CharacterID, SceneID, FieldName, Value, Evidence, Confidence)
SELECT @CharacterEnrichmentRunID, CharacterID, NULL, N'Summary', Summary, NULL, Confidence
FROM @Results
WHERE Summary IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, DateOfBirthSceneID, N'DateOfBirth', DateOfBirthValue, DateOfBirthEvidence, COALESCE(DateOfBirthConfidence, 0)
FROM @Results WHERE DateOfBirthValue IS NOT NULL AND DateOfBirthSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, AgeSceneID, N'AgeAtStartOfSeries', AgeValue, AgeEvidence, COALESCE(AgeConfidence, 0)
FROM @Results WHERE AgeValue IS NOT NULL AND AgeSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, HeightSceneID, N'Height', HeightValue, HeightEvidence, COALESCE(HeightConfidence, 0)
FROM @Results WHERE HeightValue IS NOT NULL AND HeightSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, EyeColourSceneID, N'EyeColour', EyeColourValue, EyeColourEvidence, COALESCE(EyeColourConfidence, 0)
FROM @Results WHERE EyeColourValue IS NOT NULL AND EyeColourSceneID IS NOT NULL;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Completed',
ProcessedCharacters = TotalCharacters,
CurrentStage = N'Complete',
CurrentMessage = N'Character summaries are ready.',
CompletedUtc = SYSUTCDATETIME(),
Model = @Model,
InputTokens = @InputTokens,
OutputTokens = @OutputTokens,
TotalTokens = @TotalTokens,
DurationMs = @DurationMs,
ErrorMessage = NULL,
ErrorDetail = NULL,
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Fail
@CharacterEnrichmentRunID int,
@ErrorMessage nvarchar(max),
@ErrorDetail nvarchar(max) = NULL,
@DurationMs bigint = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Failed',
FailedUtc = SYSUTCDATETIME(),
CurrentStage = N'Failed',
CurrentMessage = N'Character enrichment failed.',
ErrorMessage = @ErrorMessage,
ErrorDetail = @ErrorDetail,
DurationMs = @DurationMs,
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
END;
GO

View File

@ -1,218 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.ProjectHardDelete_PrepareDependencies
@ProjectID int,
@UserID int,
@AllowActiveProject bit = 0
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Projects p
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE p.ProjectID = @ProjectID
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1
)
THROW 52001, 'Project was not found for this owner.', 1;
IF @AllowActiveProject = 0 AND EXISTS (SELECT 1 FROM dbo.Projects WHERE ProjectID = @ProjectID AND IsArchived = 0)
THROW 52002, 'Only archived projects can be permanently deleted.', 1;
DECLARE @BookIDs TABLE (BookID int NOT NULL PRIMARY KEY);
DECLARE @ChapterIDs TABLE (ChapterID int NOT NULL PRIMARY KEY);
DECLARE @SceneIDs TABLE (SceneID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceRunIDs TABLE (StoryIntelligenceRunID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceChapterResultIDs TABLE (ChapterResultID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceSceneResultIDs TABLE (SceneResultID int NOT NULL PRIMARY KEY);
DECLARE @ImportSessionIDs TABLE (ImportSessionID int NOT NULL PRIMARY KEY);
DECLARE @CharacterIntelligenceProfileIDs TABLE (CharacterIntelligenceProfileID int NOT NULL PRIMARY KEY);
DECLARE @CharacterEnrichmentRunIDs TABLE (CharacterEnrichmentRunID int NOT NULL PRIMARY KEY);
INSERT @BookIDs (BookID)
SELECT BookID FROM dbo.Books WHERE ProjectID = @ProjectID;
INSERT @ChapterIDs (ChapterID)
SELECT c.ChapterID
FROM dbo.Chapters c
INNER JOIN @BookIDs b ON b.BookID = c.BookID;
INSERT @SceneIDs (SceneID)
SELECT s.SceneID
FROM dbo.Scenes s
INNER JOIN @ChapterIDs c ON c.ChapterID = s.ChapterID;
INSERT @ImportSessionIDs (ImportSessionID)
SELECT StoryIntelligenceBookPipelineID
FROM dbo.StoryIntelligenceBookPipelines pipeline
WHERE pipeline.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = pipeline.BookID);
INSERT @StoryIntelligenceRunIDs (StoryIntelligenceRunID)
SELECT DISTINCT r.StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns r
WHERE r.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = r.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = r.ChapterID)
OR EXISTS (SELECT 1 FROM @SceneIDs s INNER JOIN dbo.Scenes scene ON scene.SceneID = s.SceneID WHERE scene.ImportRunID = r.StoryIntelligenceRunID);
INSERT @StoryIntelligenceChapterResultIDs (ChapterResultID)
SELECT DISTINCT cr.ChapterResultID
FROM dbo.StoryIntelligenceChapterResults cr
WHERE cr.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @StoryIntelligenceRunIDs r WHERE r.StoryIntelligenceRunID = cr.StoryIntelligenceRunID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = cr.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = cr.ChapterID);
INSERT @StoryIntelligenceSceneResultIDs (SceneResultID)
SELECT DISTINCT sr.SceneResultID
FROM dbo.StoryIntelligenceSceneResults sr
WHERE sr.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @StoryIntelligenceRunIDs r WHERE r.StoryIntelligenceRunID = sr.StoryIntelligenceRunID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceChapterResultIDs cr WHERE cr.ChapterResultID = sr.ChapterResultID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = sr.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = sr.ChapterID)
OR EXISTS (SELECT 1 FROM @SceneIDs s WHERE s.SceneID = sr.SceneID);
INSERT @CharacterIntelligenceProfileIDs (CharacterIntelligenceProfileID)
SELECT DISTINCT profile.CharacterIntelligenceProfileID
FROM dbo.CharacterIntelligenceProfiles profile
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs sessionIDs WHERE sessionIDs.ImportSessionID = profile.ImportSessionID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = profile.LastUpdatedSceneResultID);
IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NOT NULL
BEGIN
INSERT @CharacterEnrichmentRunIDs (CharacterEnrichmentRunID)
SELECT DISTINCT runRows.CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = runRows.BookID)
OR EXISTS (SELECT 1 FROM @ImportSessionIDs sessionIDs WHERE sessionIDs.ImportSessionID = runRows.StoryIntelligenceBookPipelineID);
END;
IF OBJECT_ID(N'dbo.CharacterEnrichmentEvidence', N'U') IS NOT NULL
BEGIN
DELETE evidence
FROM dbo.CharacterEnrichmentEvidence evidence
LEFT JOIN @CharacterEnrichmentRunIDs runIDs ON runIDs.CharacterEnrichmentRunID = evidence.CharacterEnrichmentRunID
LEFT JOIN dbo.Characters characterRows ON characterRows.CharacterID = evidence.CharacterID
LEFT JOIN @SceneIDs sceneIDs ON sceneIDs.SceneID = evidence.SceneID
WHERE runIDs.CharacterEnrichmentRunID IS NOT NULL
OR characterRows.ProjectID = @ProjectID
OR sceneIDs.SceneID IS NOT NULL;
END;
IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NOT NULL
BEGIN
DELETE runRows
FROM dbo.CharacterEnrichmentRuns runRows
WHERE EXISTS (SELECT 1 FROM @CharacterEnrichmentRunIDs runIDs WHERE runIDs.CharacterEnrichmentRunID = runRows.CharacterEnrichmentRunID);
END;
IF OBJECT_ID(N'dbo.CoreImportBasicLocationReviews', N'U') IS NOT NULL
BEGIN
DELETE reviews
FROM dbo.CoreImportBasicLocationReviews reviews
WHERE reviews.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = reviews.BookID);
END;
DELETE suggestion
FROM dbo.SceneCharacterSuggestions suggestion
LEFT JOIN @SceneIDs scene ON scene.SceneID = suggestion.SceneID
LEFT JOIN dbo.Characters characterRows ON characterRows.CharacterID = suggestion.CharacterID
WHERE scene.SceneID IS NOT NULL
OR characterRows.ProjectID = @ProjectID;
DELETE suggestion
FROM dbo.SceneLocationSuggestions suggestion
LEFT JOIN @SceneIDs scene ON scene.SceneID = suggestion.SceneID
LEFT JOIN dbo.Locations locationRows ON locationRows.LocationID = suggestion.LocationID
WHERE scene.SceneID IS NOT NULL
OR locationRows.ProjectID = @ProjectID;
DELETE suggestion
FROM dbo.SceneAssetSuggestions suggestion
LEFT JOIN @SceneIDs scene ON scene.SceneID = suggestion.SceneID
LEFT JOIN dbo.StoryAssets assetRows ON assetRows.StoryAssetID = suggestion.AssetID
WHERE scene.SceneID IS NOT NULL
OR assetRows.ProjectID = @ProjectID;
DELETE sceneAssets
FROM dbo.WordCompanionSceneAssets sceneAssets
LEFT JOIN @SceneIDs scene ON scene.SceneID = sceneAssets.SceneID
LEFT JOIN dbo.StoryAssets assetRows ON assetRows.StoryAssetID = sceneAssets.StoryAssetID
WHERE scene.SceneID IS NOT NULL
OR assetRows.ProjectID = @ProjectID;
DELETE sceneLocations
FROM dbo.WordCompanionSceneLocations sceneLocations
LEFT JOIN @SceneIDs scene ON scene.SceneID = sceneLocations.SceneID
LEFT JOIN dbo.Locations locationRows ON locationRows.LocationID = sceneLocations.LocationID
WHERE scene.SceneID IS NOT NULL
OR locationRows.ProjectID = @ProjectID;
DELETE evidence
FROM dbo.CharacterIntelligenceEvidenceEvents evidence
WHERE EXISTS (SELECT 1 FROM @CharacterIntelligenceProfileIDs profileIDs WHERE profileIDs.CharacterIntelligenceProfileID = evidence.CharacterIntelligenceProfileID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = evidence.SceneResultID);
DELETE profileRows
FROM dbo.CharacterIntelligenceProfiles profileRows
WHERE EXISTS (SELECT 1 FROM @CharacterIntelligenceProfileIDs profileIDs WHERE profileIDs.CharacterIntelligenceProfileID = profileRows.CharacterIntelligenceProfileID);
DELETE FROM dbo.StoryMemoryIllustrationAssignments
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationAssignments.ImportSessionID);
DELETE FROM dbo.StoryMemoryIllustrationDemands
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationDemands.ImportSessionID);
DELETE FROM dbo.StoryMemoryRelationships
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryRelationships.ImportSessionID);
DELETE FROM dbo.StoryMemoryCharacterAttributes
WHERE EXISTS
(
SELECT 1
FROM dbo.StoryMemoryCharacters c
INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID
WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAttributes.StoryMemoryCharacterID
);
DELETE FROM dbo.StoryMemoryCharacterAliases
WHERE EXISTS
(
SELECT 1
FROM dbo.StoryMemoryCharacters c
INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID
WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAliases.StoryMemoryCharacterID
);
DELETE FROM dbo.StoryMemoryAssets
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryAssets.ImportSessionID);
DELETE FROM dbo.StoryMemoryLocations
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryLocations.ImportSessionID);
DELETE FROM dbo.StoryMemoryCharacters
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryCharacters.ImportSessionID);
DELETE FROM dbo.StoryMemoryProcessedSceneResults
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryProcessedSceneResults.ImportSessionID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = StoryMemoryProcessedSceneResults.SceneResultID);
DELETE FROM dbo.StoryMemoryImportPreferences
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID);
END;
GO
IF OBJECT_DEFINITION(OBJECT_ID(N'dbo.ProjectHardDelete_ForOwner')) NOT LIKE N'%ProjectHardDelete_PrepareDependencies%'
THROW 52014, 'Project hard-delete procedure does not call dependency cleanup.', 1;
GO

View File

@ -1,351 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.CharacterEnrichmentRuns', N'CharacterID') IS NULL
BEGIN
ALTER TABLE dbo.CharacterEnrichmentRuns ADD CharacterID int NULL;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_CharacterEnrichmentRuns_Characters')
BEGIN
ALTER TABLE dbo.CharacterEnrichmentRuns
ADD CONSTRAINT FK_CharacterEnrichmentRuns_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterEnrichmentRuns_ActiveCharacter' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
BEGIN
CREATE UNIQUE INDEX UX_CharacterEnrichmentRuns_ActiveCharacter
ON dbo.CharacterEnrichmentRuns(BookID, CharacterID)
WHERE CharacterID IS NOT NULL AND IsCurrent = 1 AND Status IN (N'Queued', N'Running');
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentRuns_Character_Current' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns'))
BEGIN
CREATE INDEX IX_CharacterEnrichmentRuns_Character_Current
ON dbo.CharacterEnrichmentRuns(CharacterID, IsCurrent, UpdatedUtc DESC)
INCLUDE (BookID, UserID, Status, CompletedUtc);
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_QueueCharacter
@CharacterID int,
@UserID int,
@BookID int = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ProjectID int, @ResolvedBookID int, @PipelineID int, @ExistingID int, @Version nvarchar(120), @WasCoalesced bit = 1;
SELECT TOP (1)
@ProjectID = c.ProjectID,
@ResolvedBookID = ch.BookID
FROM dbo.Characters c
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.CharacterID = @CharacterID
AND c.IsArchived = 0
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1
AND (@BookID IS NULL OR ch.BookID = @BookID)
ORDER BY ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
IF @ProjectID IS NULL OR @ResolvedBookID IS NULL
THROW 52221, 'Character could not be queued for enrichment for this user and book.', 1;
SELECT TOP (1) @PipelineID = StoryIntelligenceBookPipelineID
FROM dbo.StoryIntelligenceBookPipelines
WHERE ProjectID = @ProjectID AND BookID = @ResolvedBookID
ORDER BY StoryIntelligenceBookPipelineID DESC;
SET @Version = CONCAT(N'ManualCharacter:', @CharacterID, N':Book:', @ResolvedBookID);
BEGIN TRANSACTION;
SELECT TOP (1) @ExistingID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, HOLDLOCK)
WHERE BookID = @ResolvedBookID
AND CharacterID = @CharacterID
AND IsCurrent = 1
AND Status IN (N'Queued', N'Running')
ORDER BY CharacterEnrichmentRunID DESC;
IF @ExistingID IS NULL
BEGIN
UPDATE dbo.CharacterEnrichmentRuns
SET IsCurrent = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE BookID = @ResolvedBookID
AND CharacterID = @CharacterID
AND IsCurrent = 1;
INSERT dbo.CharacterEnrichmentRuns
(ProjectID, BookID, UserID, CharacterID, StoryIntelligenceBookPipelineID, CharacterResolutionVersion, Status, TotalCharacters, CurrentStage, CurrentMessage)
VALUES
(@ProjectID, @ResolvedBookID, @UserID, @CharacterID, @PipelineID, @Version, N'Queued', 1, N'Queued', N'Character reanalysis is queued.');
SET @ExistingID = CAST(SCOPE_IDENTITY() AS int);
SET @WasCoalesced = 0;
END;
COMMIT TRANSACTION;
SELECT runRows.*, WasCoalesced = @WasCoalesced
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.CharacterEnrichmentRunID = @ExistingID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Retry
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @PreviousID int, @ProjectID int, @PipelineID int, @Version nvarchar(120);
SELECT TOP (1)
@PreviousID = CharacterEnrichmentRunID,
@ProjectID = ProjectID,
@PipelineID = StoryIntelligenceBookPipelineID,
@Version = CharacterResolutionVersion
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND UserID = @UserID AND CharacterID IS NULL AND IsCurrent = 1 AND Status = N'Failed'
ORDER BY CharacterEnrichmentRunID DESC;
IF @PreviousID IS NULL
BEGIN
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET IsCurrent = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @PreviousID;
EXEC dbo.CharacterEnrichmentRun_Queue
@ProjectID = @ProjectID,
@BookID = @BookID,
@UserID = @UserID,
@StoryIntelligenceBookPipelineID = @PipelineID,
@CharacterResolutionVersion = @Version;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) *
FROM dbo.CharacterEnrichmentRuns
WHERE BookID = @BookID AND CharacterID IS NULL AND IsCurrent = 1
ORDER BY CharacterEnrichmentRunID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByCharacter
@CharacterID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) runRows.*
FROM dbo.CharacterEnrichmentRuns runRows
INNER JOIN dbo.Characters c ON c.CharacterID = @CharacterID AND c.ProjectID = runRows.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
WHERE runRows.CharacterID = @CharacterID
AND runRows.UserID = @UserID
AND runRows.IsCurrent = 1
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1
ORDER BY runRows.CharacterEnrichmentRunID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichment_ContextGet
@ProjectID int,
@BookID int,
@CharacterID int = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT
BookTitle = CASE WHEN NULLIF(LTRIM(RTRIM(b.Subtitle)), N'') IS NULL THEN b.BookTitle ELSE CONCAT(b.BookTitle, N': ', b.Subtitle) END,
b.StoryEra,
SeriesStartDate = CAST(NULL AS date)
FROM dbo.Books b
WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0;
SELECT DISTINCT
c.CharacterID,
c.CharacterName,
c.BirthDate,
c.AgeAtSeriesStart,
c.Height,
c.EyeColour,
c.DefaultDescription
FROM dbo.Characters c
INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND ch.BookID = @BookID
AND (@CharacterID IS NULL OR c.CharacterID = @CharacterID)
ORDER BY c.CharacterName;
SELECT alias.CharacterID, alias.Alias
FROM dbo.CharacterAliases alias
INNER JOIN dbo.Characters c ON c.CharacterID = alias.CharacterID
WHERE c.ProjectID = @ProjectID
AND c.IsArchived = 0
AND (@CharacterID IS NULL OR c.CharacterID = @CharacterID)
ORDER BY alias.CharacterID, alias.SortOrder, alias.Alias;
SELECT
sc.CharacterID,
s.SceneID,
ch.ChapterID,
ch.ChapterNumber,
ch.ChapterTitle,
s.SceneNumber,
s.SceneTitle,
sms.SourceText
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID AND c.ProjectID = @ProjectID AND c.IsArchived = 0
INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.BookID = @BookID AND ch.IsArchived = 0
INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID
WHERE @CharacterID IS NULL OR sc.CharacterID = @CharacterID
ORDER BY sc.CharacterID, ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentCharacterStatus_Get
@CharacterID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectID int;
SELECT @ProjectID = c.ProjectID
FROM dbo.Characters c
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
WHERE c.CharacterID = @CharacterID
AND c.IsArchived = 0
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1;
IF @ProjectID IS NULL
BEGIN
SELECT TOP (0)
CharacterID = CAST(NULL AS int),
BookID = CAST(NULL AS int),
CharacterEnrichmentRunID = CAST(NULL AS int),
Status = CAST(NULL AS nvarchar(30)),
CurrentStage = CAST(NULL AS nvarchar(120)),
CurrentMessage = CAST(NULL AS nvarchar(300)),
LastAnalysedUtc = CAST(NULL AS datetime2),
ErrorMessage = CAST(NULL AS nvarchar(max)),
Summary = CAST(NULL AS nvarchar(max)),
DateOfBirth = CAST(NULL AS nvarchar(500)),
AgeAtStartOfSeries = CAST(NULL AS nvarchar(500)),
Height = CAST(NULL AS nvarchar(500)),
EyeColour = CAST(NULL AS nvarchar(500)),
UpdatedUtc = CAST(NULL AS datetime2);
RETURN;
END;
DECLARE @Attempt TABLE
(
CharacterEnrichmentRunID int NULL,
BookID int NULL,
Status nvarchar(30) NULL,
CurrentStage nvarchar(120) NULL,
CurrentMessage nvarchar(300) NULL,
ErrorMessage nvarchar(max) NULL,
UpdatedUtc datetime2 NULL
);
INSERT @Attempt
SELECT TOP (1)
runRows.CharacterEnrichmentRunID,
runRows.BookID,
runRows.Status,
runRows.CurrentStage,
runRows.CurrentMessage,
runRows.ErrorMessage,
runRows.UpdatedUtc
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.UserID = @UserID
AND runRows.ProjectID = @ProjectID
AND runRows.CharacterID = @CharacterID
AND runRows.IsCurrent = 1
ORDER BY CASE runRows.Status WHEN N'Queued' THEN 0 WHEN N'Running' THEN 0 WHEN N'Failed' THEN 1 WHEN N'Completed' THEN 2 ELSE 9 END,
runRows.UpdatedUtc DESC,
runRows.CharacterEnrichmentRunID DESC;
DECLARE @SuccessRunID int, @SuccessBookID int, @LastAnalysedUtc datetime2;
SELECT TOP (1)
@SuccessRunID = runRows.CharacterEnrichmentRunID,
@SuccessBookID = runRows.BookID,
@LastAnalysedUtc = runRows.CompletedUtc
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.UserID = @UserID
AND runRows.ProjectID = @ProjectID
AND runRows.Status = N'Completed'
AND runRows.CompletedUtc IS NOT NULL
AND (
runRows.CharacterID = @CharacterID
OR EXISTS
(
SELECT 1
FROM dbo.CharacterEnrichmentEvidence evidence
WHERE evidence.CharacterEnrichmentRunID = runRows.CharacterEnrichmentRunID
AND evidence.CharacterID = @CharacterID
)
)
ORDER BY runRows.CompletedUtc DESC, runRows.CharacterEnrichmentRunID DESC;
SELECT
CharacterID = @CharacterID,
BookID = COALESCE((SELECT TOP (1) BookID FROM @Attempt), @SuccessBookID),
CharacterEnrichmentRunID = COALESCE((SELECT TOP (1) CharacterEnrichmentRunID FROM @Attempt), @SuccessRunID),
Status = COALESCE((SELECT TOP (1) Status FROM @Attempt), CASE WHEN @SuccessRunID IS NULL THEN N'Not analysed' ELSE N'Completed' END),
CurrentStage = (SELECT TOP (1) CurrentStage FROM @Attempt),
CurrentMessage = (SELECT TOP (1) CurrentMessage FROM @Attempt),
LastAnalysedUtc = @LastAnalysedUtc,
ErrorMessage = (SELECT TOP (1) ErrorMessage FROM @Attempt),
Summary = MAX(CASE WHEN evidence.FieldName = N'Summary' THEN evidence.Value END),
DateOfBirth = MAX(CASE WHEN evidence.FieldName = N'DateOfBirth' THEN evidence.Value END),
AgeAtStartOfSeries = MAX(CASE WHEN evidence.FieldName = N'AgeAtStartOfSeries' THEN evidence.Value END),
Height = MAX(CASE WHEN evidence.FieldName = N'Height' THEN evidence.Value END),
EyeColour = MAX(CASE WHEN evidence.FieldName = N'EyeColour' THEN evidence.Value END),
UpdatedUtc = COALESCE((SELECT TOP (1) UpdatedUtc FROM @Attempt), @LastAnalysedUtc, SYSUTCDATETIME())
FROM (SELECT 1 AS Anchor) anchor
LEFT JOIN dbo.CharacterEnrichmentEvidence evidence
ON evidence.CharacterEnrichmentRunID = @SuccessRunID
AND evidence.CharacterID = @CharacterID;
END;
GO

View File

@ -1,225 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceSceneResults_BookSceneRun' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceSceneResults'))
BEGIN
CREATE INDEX IX_StoryIntelligenceSceneResults_BookSceneRun
ON dbo.StoryIntelligenceSceneResults(BookID, SceneID, StoryIntelligenceRunID)
INCLUDE (ProjectID, ChapterID, CreatedUtc);
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
;WITH RunScopes AS
(
SELECT
r.UserID,
r.ProjectID,
r.BookID,
LatestCreatedUtc = MAX(r.CreatedUtc),
LatestUpdatedUtc = MAX(r.UpdatedUtc)
FROM dbo.StoryIntelligenceRuns r
INNER JOIN dbo.Projects p ON p.ProjectID = r.ProjectID AND p.IsArchived = 0
INNER JOIN dbo.Books b ON b.BookID = r.BookID AND b.IsArchived = 0
INNER JOIN dbo.ProjectUserAccess access
ON access.ProjectID = r.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
WHERE r.UserID = @UserID
AND r.BookID IS NOT NULL
AND r.Status IN (N'Pending', N'Running')
GROUP BY r.UserID, r.ProjectID, r.BookID
),
PipelineScopes AS
(
SELECT
@UserID AS UserID,
p.ProjectID,
p.BookID,
LatestCreatedUtc = MAX(COALESCE(lastRun.CreatedUtc, p.CreatedUtc)),
LatestUpdatedUtc = MAX(CASE
WHEN lastRun.UpdatedUtc IS NOT NULL AND lastRun.UpdatedUtc > p.UpdatedUtc THEN lastRun.UpdatedUtc
ELSE p.UpdatedUtc
END)
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID AND project.IsArchived = 0
INNER JOIN dbo.Books book ON book.BookID = p.BookID AND book.IsArchived = 0
INNER JOIN dbo.ProjectUserAccess access
ON access.ProjectID = p.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
LEFT JOIN dbo.StoryIntelligenceRuns lastRun ON lastRun.StoryIntelligenceRunID = p.LastRunID
WHERE p.Status = N'InProgress'
GROUP BY p.ProjectID, p.BookID
),
ActiveScopes AS
(
SELECT
scopes.UserID,
scopes.ProjectID,
scopes.BookID,
LatestCreatedUtc = MAX(scopes.LatestCreatedUtc),
LatestUpdatedUtc = MAX(scopes.LatestUpdatedUtc)
FROM
(
SELECT UserID, ProjectID, BookID, LatestCreatedUtc, LatestUpdatedUtc FROM RunScopes
UNION ALL
SELECT UserID, ProjectID, BookID, LatestCreatedUtc, LatestUpdatedUtc FROM PipelineScopes
) scopes
GROUP BY scopes.UserID, scopes.ProjectID, scopes.BookID
),
ScopedRuns AS
(
SELECT r.*
FROM dbo.StoryIntelligenceRuns r
INNER JOIN ActiveScopes scope
ON scope.UserID = r.UserID
AND ISNULL(scope.ProjectID, -1) = ISNULL(r.ProjectID, -1)
AND ISNULL(scope.BookID, -1) = ISNULL(r.BookID, -1)
AND r.CreatedUtc >= DATEADD(minute, -30, scope.LatestCreatedUtc)
WHERE r.Status IN (N'Pending', N'Running', N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled')
),
CanonicalSceneScope AS
(
SELECT DISTINCT
scope.UserID,
scope.ProjectID,
scope.BookID,
s.SceneID
FROM ActiveScopes scope
INNER JOIN dbo.Chapters c
ON c.BookID = scope.BookID
AND c.IsArchived = 0
INNER JOIN dbo.Scenes s
ON s.ChapterID = c.ChapterID
AND s.IsArchived = 0
),
RunSummary AS
(
SELECT
scope.UserID,
scope.ProjectID,
scope.BookID,
COUNT(r.StoryIntelligenceRunID) AS RunCount,
SUM(CASE WHEN r.Status = N'Pending' THEN 1 ELSE 0 END) AS PendingRunCount,
SUM(CASE WHEN r.Status = N'Running' THEN 1 ELSE 0 END) AS RunningRunCount,
SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount,
SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount,
SUM(ISNULL(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes,
SUM(ISNULL(r.CompletedScenes, 0)) AS CompletedScenes,
SUM(ISNULL(r.FailedScenes, 0)) AS FailedScenes,
SUM(CASE
WHEN r.Status IN (N'Pending', N'Running')
THEN CASE
WHEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0) > 0
THEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0)
ELSE 0
END
ELSE 0
END) AS ActiveScenes,
SUM(ISNULL(r.TotalDurationMs, 0)) AS TotalDurationMs,
MIN(COALESCE(r.CreatedUtc, scope.LatestCreatedUtc)) AS FirstCreatedUtc,
MAX(CASE
WHEN r.UpdatedUtc IS NOT NULL AND r.UpdatedUtc > scope.LatestUpdatedUtc THEN r.UpdatedUtc
ELSE scope.LatestUpdatedUtc
END) AS UpdatedUtc
FROM ActiveScopes scope
LEFT JOIN ScopedRuns r
ON r.UserID = scope.UserID
AND ISNULL(r.ProjectID, -1) = ISNULL(scope.ProjectID, -1)
AND ISNULL(r.BookID, -1) = ISNULL(scope.BookID, -1)
GROUP BY scope.UserID, scope.ProjectID, scope.BookID
),
ExpectedSceneSummary AS
(
SELECT
scope.UserID,
scope.ProjectID,
scope.BookID,
COUNT(scope.SceneID) AS TotalExpectedScenes
FROM CanonicalSceneScope scope
GROUP BY scope.UserID, scope.ProjectID, scope.BookID
),
CompletedSceneSummary AS
(
SELECT
scope.UserID,
scope.ProjectID,
scope.BookID,
COUNT(DISTINCT results.SceneID) AS CompletedScenes
FROM CanonicalSceneScope scope
INNER JOIN dbo.StoryIntelligenceSceneResults results
ON results.SceneID = scope.SceneID
INNER JOIN dbo.StoryIntelligenceRuns runRows
ON runRows.StoryIntelligenceRunID = results.StoryIntelligenceRunID
AND runRows.UserID = scope.UserID
AND ISNULL(runRows.ProjectID, -1) = ISNULL(scope.ProjectID, -1)
AND ISNULL(runRows.BookID, -1) = ISNULL(scope.BookID, -1)
GROUP BY scope.UserID, scope.ProjectID, scope.BookID
)
SELECT
summary.UserID,
summary.ProjectID,
p.ProjectName AS ProjectTitle,
summary.BookID,
b.BookTitle,
b.Subtitle AS BookSubtitle,
summary.RunCount,
summary.PendingRunCount,
summary.RunningRunCount,
summary.CompletedRunCount,
summary.FailedRunCount,
COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) AS TotalExpectedScenes,
summary.TotalDetectedScenes,
COALESCE(completed.CompletedScenes, summary.CompletedScenes, 0) AS CompletedScenes,
summary.FailedScenes,
summary.ActiveScenes,
CASE
WHEN COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) <= 0 THEN 0
ELSE CONVERT(int, ROUND(COALESCE(completed.CompletedScenes, summary.CompletedScenes, 0) * 100.0 / COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 1), 0))
END AS ProgressPercent,
CASE
WHEN pipeline.Status = N'InProgress'
AND summary.RunningRunCount = 0
AND summary.PendingRunCount = 0
THEN N'Finalising'
WHEN pipeline.Status = N'InProgress'
AND COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) > 0
AND COALESCE(completed.CompletedScenes, summary.CompletedScenes, 0) + summary.FailedScenes >= COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0)
THEN N'Finalising'
WHEN summary.RunningRunCount > 0 THEN N'Running'
WHEN summary.PendingRunCount > 0 THEN N'Pending'
WHEN summary.FailedRunCount > 0 THEN N'Failed'
WHEN summary.FailedScenes > 0 THEN N'CompletedWithWarnings'
ELSE N'Completed'
END AS Status,
summary.TotalDurationMs,
summary.FirstCreatedUtc,
summary.UpdatedUtc
FROM RunSummary summary
LEFT JOIN ExpectedSceneSummary expected
ON expected.UserID = summary.UserID
AND ISNULL(expected.ProjectID, -1) = ISNULL(summary.ProjectID, -1)
AND ISNULL(expected.BookID, -1) = ISNULL(summary.BookID, -1)
LEFT JOIN CompletedSceneSummary completed
ON completed.UserID = summary.UserID
AND ISNULL(completed.ProjectID, -1) = ISNULL(summary.ProjectID, -1)
AND ISNULL(completed.BookID, -1) = ISNULL(summary.BookID, -1)
LEFT JOIN dbo.StoryIntelligenceBookPipelines pipeline
ON pipeline.BookID = summary.BookID
AND pipeline.ProjectID = summary.ProjectID
LEFT JOIN dbo.Projects p ON p.ProjectID = summary.ProjectID
LEFT JOIN dbo.Books b ON b.BookID = summary.BookID
WHERE summary.PendingRunCount > 0
OR summary.RunningRunCount > 0
OR pipeline.Status = N'InProgress'
ORDER BY summary.UpdatedUtc DESC, summary.FirstCreatedUtc;
END;
GO

View File

@ -1,7 +0,0 @@
IF OBJECT_ID(N'dbo.CharacterEnrichmentEvidence', N'U') IS NOT NULL
AND COL_LENGTH(N'dbo.CharacterEnrichmentEvidence', N'Value') IS NOT NULL
BEGIN
ALTER TABLE dbo.CharacterEnrichmentEvidence
ALTER COLUMN Value nvarchar(max) NULL;
END;
GO

View File

@ -1,472 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_ClaimNext
@LeaseMinutes int = 90
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RunID int;
BEGIN TRANSACTION;
SELECT TOP (1) @RunID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, HOLDLOCK, READPAST)
WHERE IsCurrent = 1
AND (
Status = N'Queued'
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
)
ORDER BY QueuedUtc, CharacterEnrichmentRunID;
IF @RunID IS NULL
BEGIN
COMMIT TRANSACTION;
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Running',
StartedUtc = SYSUTCDATETIME(),
AttemptCount = AttemptCount + 1,
CurrentStage = N'Preparing character analysis',
CurrentMessage = N'Preparing character analysis...',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @RunID
AND IsCurrent = 1
AND (
Status = N'Queued'
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
);
IF @@ROWCOUNT = 0
BEGIN
COMMIT TRANSACTION;
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @RunID;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Complete
@CharacterEnrichmentRunID int,
@Model nvarchar(100),
@InputTokens int = NULL,
@OutputTokens int = NULL,
@TotalTokens int = NULL,
@DurationMs bigint = NULL,
@ResultsJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ProjectID int, @BookID int, @PipelineID int;
DECLARE @Results TABLE
(
CharacterID int NOT NULL PRIMARY KEY,
Summary nvarchar(max) NULL,
Confidence decimal(5,4) NOT NULL,
DateOfBirthValue nvarchar(120) NULL,
DateOfBirthConfidence decimal(5,4) NULL,
DateOfBirthEvidence nvarchar(700) NULL,
DateOfBirthSceneID int NULL,
AgeValue nvarchar(120) NULL,
AgeConfidence decimal(5,4) NULL,
AgeEvidence nvarchar(700) NULL,
AgeSceneID int NULL,
SexValue nvarchar(120) NULL,
SexConfidence decimal(5,4) NULL,
SexEvidence nvarchar(700) NULL,
SexSceneID int NULL,
HeightValue nvarchar(120) NULL,
HeightConfidence decimal(5,4) NULL,
HeightEvidence nvarchar(700) NULL,
HeightSceneID int NULL,
EyeColourValue nvarchar(120) NULL,
EyeColourConfidence decimal(5,4) NULL,
EyeColourEvidence nvarchar(700) NULL,
EyeColourSceneID int NULL
);
INSERT @Results
SELECT
CharacterID,
NULLIF(LTRIM(RTRIM(Summary)), N''),
Confidence,
NULLIF(LTRIM(RTRIM(DateOfBirthValue)), N''),
DateOfBirthConfidence,
NULLIF(LTRIM(RTRIM(DateOfBirthEvidence)), N''),
DateOfBirthSceneID,
NULLIF(LTRIM(RTRIM(AgeValue)), N''),
AgeConfidence,
NULLIF(LTRIM(RTRIM(AgeEvidence)), N''),
AgeSceneID,
CASE
WHEN LOWER(NULLIF(LTRIM(RTRIM(SexValue)), N'')) = N'female' THEN N'Female'
WHEN LOWER(NULLIF(LTRIM(RTRIM(SexValue)), N'')) = N'male' THEN N'Male'
WHEN LOWER(NULLIF(LTRIM(RTRIM(SexValue)), N'')) = N'other' THEN N'Other'
ELSE NULL
END,
SexConfidence,
NULLIF(LTRIM(RTRIM(SexEvidence)), N''),
SexSceneID,
NULLIF(LTRIM(RTRIM(HeightValue)), N''),
HeightConfidence,
NULLIF(LTRIM(RTRIM(HeightEvidence)), N''),
HeightSceneID,
NULLIF(LTRIM(RTRIM(EyeColourValue)), N''),
EyeColourConfidence,
NULLIF(LTRIM(RTRIM(EyeColourEvidence)), N''),
EyeColourSceneID
FROM OPENJSON(@ResultsJson)
WITH
(
CharacterID int '$.characterID',
Summary nvarchar(max) '$.summary',
Confidence decimal(5,4) '$.confidence',
DateOfBirthValue nvarchar(120) '$.dateOfBirth.value',
DateOfBirthConfidence decimal(5,4) '$.dateOfBirth.confidence',
DateOfBirthEvidence nvarchar(700) '$.dateOfBirth.evidence',
DateOfBirthSceneID int '$.dateOfBirth.sceneID',
AgeValue nvarchar(120) '$.ageAtStartOfSeries.value',
AgeConfidence decimal(5,4) '$.ageAtStartOfSeries.confidence',
AgeEvidence nvarchar(700) '$.ageAtStartOfSeries.evidence',
AgeSceneID int '$.ageAtStartOfSeries.sceneID',
SexValue nvarchar(120) '$.sex.value',
SexConfidence decimal(5,4) '$.sex.confidence',
SexEvidence nvarchar(700) '$.sex.evidence',
SexSceneID int '$.sex.sceneID',
HeightValue nvarchar(120) '$.height.value',
HeightConfidence decimal(5,4) '$.height.confidence',
HeightEvidence nvarchar(700) '$.height.evidence',
HeightSceneID int '$.height.sceneID',
EyeColourValue nvarchar(120) '$.eyeColour.value',
EyeColourConfidence decimal(5,4) '$.eyeColour.confidence',
EyeColourEvidence nvarchar(700) '$.eyeColour.evidence',
EyeColourSceneID int '$.eyeColour.sceneID'
)
WHERE CharacterID IS NOT NULL;
DECLARE @SexValues TABLE (SexName nvarchar(120) NOT NULL PRIMARY KEY, CharacterSexValueID int NOT NULL);
INSERT @SexValues (SexName, CharacterSexValueID)
SELECT SexName, CharacterSexValueID
FROM dbo.CharacterSexValues
WHERE IsSystem = 1
AND IsActive = 1
AND SexName IN (N'Male', N'Female', N'Other');
BEGIN TRANSACTION;
SELECT @ProjectID = ProjectID, @BookID = BookID, @PipelineID = StoryIntelligenceBookPipelineID
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, HOLDLOCK)
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
UPDATE c
SET DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL THEN r.Summary ELSE c.DefaultDescription END,
BirthDate = CASE WHEN c.BirthDate IS NULL THEN TRY_CONVERT(date, r.DateOfBirthValue) ELSE c.BirthDate END,
AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL THEN TRY_CONVERT(int, r.AgeValue) ELSE c.AgeAtSeriesStart END,
SexValueID = CASE
WHEN c.SexValueID IS NULL
AND (NULLIF(LTRIM(RTRIM(c.Sex)), N'') IS NULL OR c.Sex = N'Unknown')
AND sexValue.CharacterSexValueID IS NOT NULL
THEN sexValue.CharacterSexValueID
ELSE c.SexValueID
END,
Sex = CASE
WHEN c.SexValueID IS NULL
AND (NULLIF(LTRIM(RTRIM(c.Sex)), N'') IS NULL OR c.Sex = N'Unknown')
AND sexValue.CharacterSexValueID IS NOT NULL
THEN r.SexValue
ELSE c.Sex
END,
Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL THEN r.HeightValue ELSE c.Height END,
EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL THEN r.EyeColourValue ELSE c.EyeColour END,
UpdatedDate = SYSUTCDATETIME()
FROM dbo.Characters c
INNER JOIN @Results r ON r.CharacterID = c.CharacterID
LEFT JOIN @SexValues sexValue ON sexValue.SexName = r.SexValue
WHERE c.ProjectID = @ProjectID;
MERGE dbo.CharacterIntelligenceProfiles WITH (HOLDLOCK) AS target
USING
(
SELECT
ImportSessionID = @PipelineID,
ProjectID = @ProjectID,
BookID = @BookID,
CanonicalIdentityKey = CONCAT(N'character-enrichment-', r.CharacterID),
CanonicalName = c.CharacterName,
ProfileJson = (SELECT r.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
Confidence = r.Confidence,
NarrativeImportance = CAST(NULL AS nvarchar(60))
FROM @Results r
INNER JOIN dbo.Characters c ON c.CharacterID = r.CharacterID
WHERE @PipelineID IS NOT NULL
) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.CanonicalIdentityKey = source.CanonicalIdentityKey
WHEN MATCHED THEN
UPDATE SET CanonicalName = source.CanonicalName,
ProfileJson = source.ProfileJson,
Confidence = source.Confidence,
EvidenceVersion = N'CharacterEnrichmentV1',
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, CanonicalName, ProfileJson, Confidence, NarrativeImportance, IsRecurring, EvidenceVersion, LastUpdatedSceneResultID)
VALUES (source.ImportSessionID, source.ProjectID, source.BookID, source.CanonicalIdentityKey, source.CanonicalName, source.ProfileJson, source.Confidence, source.NarrativeImportance, 1, N'CharacterEnrichmentV1', NULL);
DELETE evidence
FROM dbo.CharacterEnrichmentEvidence evidence
INNER JOIN @Results r ON r.CharacterID = evidence.CharacterID
WHERE evidence.CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
INSERT dbo.CharacterEnrichmentEvidence (CharacterEnrichmentRunID, CharacterID, SceneID, FieldName, Value, Evidence, Confidence)
SELECT @CharacterEnrichmentRunID, CharacterID, NULL, N'Summary', Summary, NULL, Confidence
FROM @Results
WHERE Summary IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, DateOfBirthSceneID, N'DateOfBirth', DateOfBirthValue, DateOfBirthEvidence, COALESCE(DateOfBirthConfidence, 0)
FROM @Results WHERE DateOfBirthValue IS NOT NULL AND DateOfBirthSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, AgeSceneID, N'AgeAtStartOfSeries', AgeValue, AgeEvidence, COALESCE(AgeConfidence, 0)
FROM @Results WHERE AgeValue IS NOT NULL AND AgeSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, SexSceneID, N'Sex', SexValue, SexEvidence, COALESCE(SexConfidence, 0)
FROM @Results WHERE SexValue IS NOT NULL AND SexSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, HeightSceneID, N'Height', HeightValue, HeightEvidence, COALESCE(HeightConfidence, 0)
FROM @Results WHERE HeightValue IS NOT NULL AND HeightSceneID IS NOT NULL
UNION ALL
SELECT @CharacterEnrichmentRunID, CharacterID, EyeColourSceneID, N'EyeColour', EyeColourValue, EyeColourEvidence, COALESCE(EyeColourConfidence, 0)
FROM @Results WHERE EyeColourValue IS NOT NULL AND EyeColourSceneID IS NOT NULL;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Completed',
ProcessedCharacters = TotalCharacters,
CurrentStage = N'Complete',
CurrentMessage = N'Character summaries are ready.',
CompletedUtc = SYSUTCDATETIME(),
Model = @Model,
InputTokens = @InputTokens,
OutputTokens = @OutputTokens,
TotalTokens = @TotalTokens,
DurationMs = @DurationMs,
ErrorMessage = NULL,
ErrorDetail = NULL,
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentCharacterStatus_Get
@CharacterID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectID int;
SELECT @ProjectID = c.ProjectID
FROM dbo.Characters c
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID
WHERE c.CharacterID = @CharacterID
AND c.IsArchived = 0
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1;
IF @ProjectID IS NULL
BEGIN
SELECT TOP (0)
CharacterID = CAST(NULL AS int),
BookID = CAST(NULL AS int),
CharacterEnrichmentRunID = CAST(NULL AS int),
Status = CAST(NULL AS nvarchar(30)),
CurrentStage = CAST(NULL AS nvarchar(120)),
CurrentMessage = CAST(NULL AS nvarchar(300)),
LastAnalysedUtc = CAST(NULL AS datetime2),
ErrorMessage = CAST(NULL AS nvarchar(max)),
Summary = CAST(NULL AS nvarchar(max)),
DateOfBirth = CAST(NULL AS nvarchar(500)),
AgeAtStartOfSeries = CAST(NULL AS nvarchar(500)),
Sex = CAST(NULL AS nvarchar(500)),
Height = CAST(NULL AS nvarchar(500)),
EyeColour = CAST(NULL AS nvarchar(500)),
UpdatedUtc = CAST(NULL AS datetime2);
RETURN;
END;
DECLARE @Attempt TABLE
(
CharacterEnrichmentRunID int NULL,
BookID int NULL,
Status nvarchar(30) NULL,
CurrentStage nvarchar(120) NULL,
CurrentMessage nvarchar(300) NULL,
ErrorMessage nvarchar(max) NULL,
UpdatedUtc datetime2 NULL
);
INSERT @Attempt
SELECT TOP (1)
runRows.CharacterEnrichmentRunID,
runRows.BookID,
runRows.Status,
runRows.CurrentStage,
runRows.CurrentMessage,
runRows.ErrorMessage,
runRows.UpdatedUtc
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.UserID = @UserID
AND runRows.ProjectID = @ProjectID
AND runRows.CharacterID = @CharacterID
AND runRows.IsCurrent = 1
ORDER BY CASE runRows.Status WHEN N'Queued' THEN 0 WHEN N'Running' THEN 0 WHEN N'Failed' THEN 1 WHEN N'Completed' THEN 2 ELSE 9 END,
runRows.UpdatedUtc DESC,
runRows.CharacterEnrichmentRunID DESC;
DECLARE @SuccessRunID int, @SuccessBookID int, @LastAnalysedUtc datetime2;
SELECT TOP (1)
@SuccessRunID = runRows.CharacterEnrichmentRunID,
@SuccessBookID = runRows.BookID,
@LastAnalysedUtc = runRows.CompletedUtc
FROM dbo.CharacterEnrichmentRuns runRows
WHERE runRows.UserID = @UserID
AND runRows.ProjectID = @ProjectID
AND runRows.Status = N'Completed'
AND runRows.CompletedUtc IS NOT NULL
AND (
runRows.CharacterID = @CharacterID
OR EXISTS
(
SELECT 1
FROM dbo.CharacterEnrichmentEvidence evidence
WHERE evidence.CharacterEnrichmentRunID = runRows.CharacterEnrichmentRunID
AND evidence.CharacterID = @CharacterID
)
)
ORDER BY runRows.CompletedUtc DESC, runRows.CharacterEnrichmentRunID DESC;
SELECT
CharacterID = @CharacterID,
BookID = COALESCE((SELECT TOP (1) BookID FROM @Attempt), @SuccessBookID),
CharacterEnrichmentRunID = COALESCE((SELECT TOP (1) CharacterEnrichmentRunID FROM @Attempt), @SuccessRunID),
Status = COALESCE((SELECT TOP (1) Status FROM @Attempt), CASE WHEN @SuccessRunID IS NULL THEN N'Not analysed' ELSE N'Completed' END),
CurrentStage = (SELECT TOP (1) CurrentStage FROM @Attempt),
CurrentMessage = (SELECT TOP (1) CurrentMessage FROM @Attempt),
LastAnalysedUtc = @LastAnalysedUtc,
ErrorMessage = (SELECT TOP (1) ErrorMessage FROM @Attempt),
Summary = MAX(CASE WHEN evidence.FieldName = N'Summary' THEN evidence.Value END),
DateOfBirth = MAX(CASE WHEN evidence.FieldName = N'DateOfBirth' THEN evidence.Value END),
AgeAtStartOfSeries = MAX(CASE WHEN evidence.FieldName = N'AgeAtStartOfSeries' THEN evidence.Value END),
Sex = MAX(CASE WHEN evidence.FieldName = N'Sex' THEN evidence.Value END),
Height = MAX(CASE WHEN evidence.FieldName = N'Height' THEN evidence.Value END),
EyeColour = MAX(CASE WHEN evidence.FieldName = N'EyeColour' THEN evidence.Value END),
UpdatedUtc = COALESCE((SELECT TOP (1) UpdatedUtc FROM @Attempt), @LastAnalysedUtc, SYSUTCDATETIME())
FROM (SELECT 1 AS Anchor) anchor
LEFT JOIN dbo.CharacterEnrichmentEvidence evidence
ON evidence.CharacterEnrichmentRunID = @SuccessRunID
AND evidence.CharacterID = @CharacterID;
END;
GO
;WITH LatestEvidence AS
(
SELECT
evidence.CharacterID,
evidence.FieldName,
evidence.Value,
runRows.ProjectID,
rn = ROW_NUMBER() OVER
(
PARTITION BY evidence.CharacterID, evidence.FieldName
ORDER BY runRows.CompletedUtc DESC, evidence.CharacterEnrichmentEvidenceID DESC
)
FROM dbo.CharacterEnrichmentEvidence evidence
INNER JOIN dbo.CharacterEnrichmentRuns runRows ON runRows.CharacterEnrichmentRunID = evidence.CharacterEnrichmentRunID
WHERE runRows.Status = N'Completed'
AND evidence.FieldName IN (N'DateOfBirth', N'AgeAtStartOfSeries', N'Height', N'EyeColour', N'Sex')
),
Pivoted AS
(
SELECT
CharacterID,
ProjectID = MAX(ProjectID),
DateOfBirthValue = MAX(CASE WHEN FieldName = N'DateOfBirth' THEN Value END),
AgeValue = MAX(CASE WHEN FieldName = N'AgeAtStartOfSeries' THEN Value END),
HeightValue = MAX(CASE WHEN FieldName = N'Height' THEN Value END),
EyeColourValue = MAX(CASE WHEN FieldName = N'EyeColour' THEN Value END),
SexValue = MAX(CASE WHEN FieldName = N'Sex' THEN Value END)
FROM LatestEvidence
WHERE rn = 1
GROUP BY CharacterID
),
Normalised AS
(
SELECT
p.CharacterID,
p.ProjectID,
DateOfBirthValue = TRY_CONVERT(date, p.DateOfBirthValue),
AgeValue = TRY_CONVERT(int, CASE
WHEN p.AgeValue IS NULL THEN NULL
WHEN PATINDEX(N'%[^0-9]%', p.AgeValue + N'x') = 1 THEN NULL
ELSE LEFT(p.AgeValue, PATINDEX(N'%[^0-9]%', p.AgeValue + N'x') - 1)
END),
p.HeightValue,
p.EyeColourValue,
SexValue = CASE
WHEN LOWER(NULLIF(LTRIM(RTRIM(p.SexValue)), N'')) = N'female' THEN N'Female'
WHEN LOWER(NULLIF(LTRIM(RTRIM(p.SexValue)), N'')) = N'male' THEN N'Male'
WHEN LOWER(NULLIF(LTRIM(RTRIM(p.SexValue)), N'')) = N'other' THEN N'Other'
ELSE NULL
END
FROM Pivoted p
),
SexValues AS
(
SELECT SexName, CharacterSexValueID
FROM dbo.CharacterSexValues
WHERE IsSystem = 1
AND IsActive = 1
AND SexName IN (N'Male', N'Female', N'Other')
)
UPDATE c
SET BirthDate = CASE WHEN c.BirthDate IS NULL THEN n.DateOfBirthValue ELSE c.BirthDate END,
AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL THEN n.AgeValue ELSE c.AgeAtSeriesStart END,
Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL THEN n.HeightValue ELSE c.Height END,
EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL THEN n.EyeColourValue ELSE c.EyeColour END,
SexValueID = CASE
WHEN c.SexValueID IS NULL
AND (NULLIF(LTRIM(RTRIM(c.Sex)), N'') IS NULL OR c.Sex = N'Unknown')
AND sexValue.CharacterSexValueID IS NOT NULL
THEN sexValue.CharacterSexValueID
ELSE c.SexValueID
END,
Sex = CASE
WHEN c.SexValueID IS NULL
AND (NULLIF(LTRIM(RTRIM(c.Sex)), N'') IS NULL OR c.Sex = N'Unknown')
AND sexValue.CharacterSexValueID IS NOT NULL
THEN n.SexValue
ELSE c.Sex
END,
UpdatedDate = SYSUTCDATETIME()
FROM dbo.Characters c
INNER JOIN Normalised n ON n.CharacterID = c.CharacterID
LEFT JOIN SexValues sexValue ON sexValue.SexName = n.SexValue
WHERE c.ProjectID = n.ProjectID
AND (
(c.BirthDate IS NULL AND n.DateOfBirthValue IS NOT NULL)
OR (c.AgeAtSeriesStart IS NULL AND n.AgeValue IS NOT NULL)
OR (NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL AND n.HeightValue IS NOT NULL)
OR (NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL AND n.EyeColourValue IS NOT NULL)
OR (c.SexValueID IS NULL AND (NULLIF(LTRIM(RTRIM(c.Sex)), N'') IS NULL OR c.Sex = N'Unknown') AND sexValue.CharacterSexValueID IS NOT NULL)
);
GO

View File

@ -1,95 +0,0 @@
;WITH MonthNames AS
(
SELECT MonthNumber = 1, MonthName = N'January' UNION ALL
SELECT 2, N'February' UNION ALL
SELECT 3, N'March' UNION ALL
SELECT 4, N'April' UNION ALL
SELECT 5, N'May' UNION ALL
SELECT 6, N'June' UNION ALL
SELECT 7, N'July' UNION ALL
SELECT 8, N'August' UNION ALL
SELECT 9, N'September' UNION ALL
SELECT 10, N'October' UNION ALL
SELECT 11, N'November' UNION ALL
SELECT 12, N'December'
),
LatestAgeEvidence AS
(
SELECT
evidence.CharacterID,
runRows.ProjectID,
EvidenceText = CONCAT(evidence.Value, N' ', evidence.Evidence),
AgeValue = TRY_CONVERT(int, CASE
WHEN evidence.Value IS NULL THEN NULL
WHEN PATINDEX(N'%[^0-9]%', evidence.Value + N'x') = 1 THEN NULL
ELSE LEFT(evidence.Value, PATINDEX(N'%[^0-9]%', evidence.Value + N'x') - 1)
END),
rn = ROW_NUMBER() OVER
(
PARTITION BY evidence.CharacterID
ORDER BY runRows.CompletedUtc DESC, evidence.CharacterEnrichmentEvidenceID DESC
)
FROM dbo.CharacterEnrichmentEvidence evidence
INNER JOIN dbo.CharacterEnrichmentRuns runRows ON runRows.CharacterEnrichmentRunID = evidence.CharacterEnrichmentRunID
WHERE runRows.Status = N'Completed'
AND evidence.FieldName = N'AgeAtStartOfSeries'
),
MatchedMonth AS
(
SELECT
ageEvidence.CharacterID,
ageEvidence.ProjectID,
ageEvidence.AgeValue,
monthNames.MonthNumber,
MonthPosition = CHARINDEX(monthNames.MonthName, ageEvidence.EvidenceText),
ageEvidence.EvidenceText
FROM LatestAgeEvidence ageEvidence
INNER JOIN MonthNames monthNames ON CHARINDEX(monthNames.MonthName, ageEvidence.EvidenceText) > 0
WHERE ageEvidence.rn = 1
AND ageEvidence.AgeValue BETWEEN 1 AND 120
),
Parts AS
(
SELECT
matched.CharacterID,
matched.ProjectID,
matched.AgeValue,
matched.MonthNumber,
DayValue = TRY_CONVERT(int, REPLACE(REPLACE(REPLACE(REPLACE(dayParts.DayToken, N'st', N''), N'nd', N''), N'rd', N''), N'th', N'')),
YearValue = TRY_CONVERT(int, LEFT(LTRIM(SUBSTRING(matched.EvidenceText, matched.MonthPosition + LEN(monthNames.MonthName), 20)), 4))
FROM MatchedMonth matched
INNER JOIN MonthNames monthNames ON monthNames.MonthNumber = matched.MonthNumber
CROSS APPLY
(
SELECT Prefix = RTRIM(CASE WHEN matched.MonthPosition > 1 THEN LEFT(matched.EvidenceText, matched.MonthPosition - 1) ELSE N'' END)
) prefixParts
CROSS APPLY
(
SELECT LastSpaceFromEnd = CHARINDEX(N' ', REVERSE(prefixParts.Prefix))
) spaceParts
CROSS APPLY
(
SELECT DayToken = CASE
WHEN LEN(prefixParts.Prefix) = 0 THEN N''
WHEN spaceParts.LastSpaceFromEnd = 0 THEN prefixParts.Prefix
ELSE RIGHT(prefixParts.Prefix, spaceParts.LastSpaceFromEnd - 1)
END
) dayParts
),
Derived AS
(
SELECT
CharacterID,
ProjectID,
DateOfBirth = DATEADD(year, -AgeValue, DATEFROMPARTS(YearValue, MonthNumber, DayValue))
FROM Parts
WHERE DayValue BETWEEN 1 AND 31
AND YearValue BETWEEN 1000 AND 9999
)
UPDATE c
SET BirthDate = derived.DateOfBirth,
UpdatedDate = SYSUTCDATETIME()
FROM dbo.Characters c
INNER JOIN Derived derived ON derived.CharacterID = c.CharacterID AND derived.ProjectID = c.ProjectID
WHERE c.BirthDate IS NULL;
GO

View File

@ -1,53 +0,0 @@
CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_ClaimNext
@LeaseMinutes int = 90
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RunID int;
BEGIN TRANSACTION;
SELECT TOP (1) @RunID = CharacterEnrichmentRunID
FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, HOLDLOCK)
WHERE IsCurrent = 1
AND (
Status = N'Queued'
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
)
ORDER BY QueuedUtc, CharacterEnrichmentRunID;
IF @RunID IS NULL
BEGIN
COMMIT TRANSACTION;
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
UPDATE dbo.CharacterEnrichmentRuns
SET Status = N'Running',
StartedUtc = SYSUTCDATETIME(),
AttemptCount = AttemptCount + 1,
CurrentStage = N'Preparing character analysis',
CurrentMessage = N'Preparing character analysis...',
UpdatedUtc = SYSUTCDATETIME()
WHERE CharacterEnrichmentRunID = @RunID
AND IsCurrent = 1
AND (
Status = N'Queued'
OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME()))
);
IF @@ROWCOUNT = 0
BEGIN
COMMIT TRANSACTION;
SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns;
RETURN;
END;
SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @RunID;
COMMIT TRANSACTION;
END;
GO

View File

@ -1,66 +0,0 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
-- Read-only composition inputs. The canonical view supplies the project-wide boundary.
CREATE OR ALTER PROCEDURE dbo.StoryPoint_GetHistory
@ProjectID int,
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @MaxIndex bigint = (SELECT GlobalSceneIndex FROM dbo.NarrativeSceneOrder WHERE ProjectID = @ProjectID AND SceneID = @SceneID);
IF @MaxIndex IS NULL THROW 50001, 'Scene does not belong to this project narrative.', 1;
SELECT SceneID INTO #Scope FROM dbo.NarrativeSceneOrder WHERE ProjectID = @ProjectID AND GlobalSceneIndex <= @MaxIndex;
CREATE UNIQUE CLUSTERED INDEX IX_Scope ON #Scope(SceneID);
SELECT ck.*, ch.CharacterName, ks.StateName AS KnowledgeStateName, sa.AssetName, pt.ThreadTitle
FROM dbo.CharacterKnowledge ck
INNER JOIN #Scope n ON n.SceneID = ck.SceneID
INNER JOIN dbo.Characters ch ON ch.CharacterID = ck.CharacterID AND ch.ProjectID = @ProjectID AND ch.IsArchived = 0
INNER JOIN dbo.KnowledgeStates ks ON ks.KnowledgeStateID = ck.KnowledgeStateID
LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ck.StoryAssetID AND sa.ProjectID = @ProjectID
LEFT JOIN dbo.PlotThreads pt ON pt.PlotThreadID = ck.PlotThreadID;
SELECT sal.*, sa.AssetName, lp.LocationName, lp.LocationPath
FROM dbo.SceneAssetLocations sal
INNER JOIN #Scope n ON n.SceneID = sal.SceneID
INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = sal.StoryAssetID AND sa.ProjectID = @ProjectID AND sa.IsArchived = 0
INNER JOIN dbo.LocationPaths lp ON lp.LocationID = sal.LocationID;
SELECT ace.*, sa.AssetName, acet.TypeName AS CustodyEventTypeName,
holders.CustodianSummary
FROM dbo.AssetCustodyEvents ace
INNER JOIN #Scope n ON n.SceneID = ace.SceneID
INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID AND sa.ProjectID = @ProjectID AND sa.IsArchived = 0
INNER JOIN dbo.AssetCustodyEventTypes acet ON acet.AssetCustodyEventTypeID = ace.AssetCustodyEventTypeID
OUTER APPLY (SELECT STRING_AGG(CONCAT(COALESCE(ch.CharacterName, aec.CharacterNameText), N' (', cr.RoleName, N')'), N', ') AS CustodianSummary
FROM dbo.AssetCustodyEventCharacters aec
LEFT JOIN dbo.Characters ch ON ch.CharacterID = aec.CharacterID
LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = aec.CustodyRoleID
WHERE aec.AssetCustodyEventID = ace.AssetCustodyEventID) holders;
SELECT ace.AssetCustodyEventID, ace.StoryAssetID, ace.SceneID, aec.CharacterID,
ch.CharacterName, aec.CharacterNameText, cr.RoleName, ace.UpdatedDate
FROM dbo.AssetCustodyEvents ace
INNER JOIN #Scope n ON n.SceneID = ace.SceneID
INNER JOIN dbo.AssetCustodyEventCharacters aec ON aec.AssetCustodyEventID = ace.AssetCustodyEventID
LEFT JOIN dbo.Characters ch ON ch.CharacterID = aec.CharacterID
LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = aec.CustodyRoleID;
SELECT pt.*, pl.ProjectID, pl.PlotLineName, tt.TypeName AS ThreadTypeName
FROM dbo.PlotThreads pt
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID
INNER JOIN dbo.ThreadTypes tt ON tt.ThreadTypeID = pt.ThreadTypeID
WHERE pl.ProjectID = @ProjectID AND pl.IsArchived = 0 AND pt.IsArchived = 0
AND (EXISTS (SELECT 1 FROM #Scope WHERE SceneID = pt.IntroducedSceneID)
OR (pt.IntroducedSceneID IS NULL AND EXISTS (SELECT 1 FROM dbo.ThreadEvents te INNER JOIN #Scope n ON n.SceneID = te.SceneID WHERE te.PlotThreadID = pt.PlotThreadID)));
SELECT te.*, tet.TypeName AS EventTypeName
FROM dbo.ThreadEvents te
INNER JOIN #Scope n ON n.SceneID = te.SceneID
INNER JOIN dbo.PlotThreads pt ON pt.PlotThreadID = te.PlotThreadID AND pt.IsArchived = 0
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID AND pl.ProjectID = @ProjectID AND pl.IsArchived = 0
INNER JOIN dbo.ThreadEventTypes tet ON tet.ThreadEventTypeID = te.EventTypeID;
END;
GO

View File

@ -1,214 +0,0 @@
-- Deterministic assessments reuse persisted scene intelligence; no new AI or decision resets.
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceLocationReview_List @BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT c.* FROM dbo.StoryIntelligenceReviewCandidates c
INNER JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID = c.ReviewCandidateGenerationID
WHERE g.BookID = @BookID AND g.Module = N'Locations' AND g.IsCurrent = 1 AND g.Status = N'Ready'
ORDER BY c.SortOrder, c.ReviewCandidateID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceLocationReview_BackfillSignificance @BookID int, @AssessmentsJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
UPDATE c SET CandidatePayloadJson = JSON_MODIFY(c.CandidatePayloadJson, '$.significance', JSON_QUERY(a.Assessment))
FROM dbo.StoryIntelligenceReviewCandidates c
INNER JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID = c.ReviewCandidateGenerationID
INNER JOIN OPENJSON(@AssessmentsJson) WITH (StableKey nvarchar(450) '$.key', Assessment nvarchar(max) '$.assessment' AS JSON) a ON a.StableKey = c.StableKey
WHERE g.BookID = @BookID AND g.Module = N'Locations' AND g.IsCurrent = 1 AND g.Status = N'Ready'
AND a.Assessment IS NOT NULL;
END;
GO
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCentre_GetSummary
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
;WITH RunSummary AS
(
SELECT
r.BookID,
COUNT_BIG(*) AS RunCount,
SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount,
SUM(CASE WHEN r.Status IN (N'Pending', N'Running') THEN 1 ELSE 0 END) AS ActiveRunCount,
SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount,
SUM(COALESCE(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes,
SUM(COALESCE(r.CompletedScenes, 0)) AS CompletedScenes,
SUM(COALESCE(r.FailedScenes, 0)) AS FailedScenes
FROM dbo.StoryIntelligenceRuns r
WHERE r.BookID = @BookID
GROUP BY r.BookID
),
CandidateSummary AS
(
SELECT
generation.BookID,
MAX(CASE WHEN generation.Module = N'Characters' THEN generation.Status END) AS CharacterCandidateGenerationStatus,
MAX(CASE WHEN generation.Module = N'Characters' THEN generation.CandidateCount END) AS CharacterCandidateCount,
MAX(CASE WHEN generation.Module = N'Characters' THEN generation.PendingCount END) AS CharacterPendingCount,
MAX(CASE WHEN generation.Module = N'Locations' THEN generation.Status END) AS LocationCandidateGenerationStatus,
MAX(CASE WHEN generation.Module = N'Locations' THEN generation.CandidateCount END) AS LocationCandidateCount,
MAX(CASE WHEN generation.Module = N'Locations' THEN generation.PendingCount END) AS LocationPendingCount,
MAX(CASE WHEN generation.Module = N'Assets' THEN generation.Status END) AS AssetCandidateGenerationStatus,
MAX(CASE WHEN generation.Module = N'Assets' THEN generation.CandidateCount END) AS AssetCandidateCount,
MAX(CASE WHEN generation.Module = N'Assets' THEN generation.PendingCount END) AS AssetPendingCount,
MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.Status END) AS RelationshipCandidateGenerationStatus,
MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.CandidateCount END) AS RelationshipCandidateCount,
MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.PendingCount END) AS RelationshipPendingCount,
MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.Status END) AS KnowledgeCandidateGenerationStatus,
MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.CandidateCount END) AS KnowledgeCandidateCount,
MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.PendingCount END) AS KnowledgePendingCount
FROM dbo.StoryIntelligenceReviewCandidateGenerations generation
WHERE generation.BookID = @BookID
AND generation.IsCurrent = 1
GROUP BY generation.BookID
),
NarrativeSummary AS
(
SELECT
nc.BookID,
COUNT_BIG(*) AS NarrativeCandidateCount,
SUM(CASE WHEN nc.Status = N'Pending' THEN 1 ELSE 0 END) AS NarrativePendingCount,
SUM(CASE WHEN nc.CandidateType = N'Thread' THEN 1 ELSE 0 END) AS NarrativeThreadCount
FROM dbo.StoryIntelligenceNarrativeCandidates nc
WHERE nc.BookID = @BookID
AND nc.WholeBookPlotSynthesisRunID IS NOT NULL
GROUP BY nc.BookID
),
NarrativeEventSummary AS
(
SELECT
nc.BookID,
COUNT_BIG(*) AS NarrativeEventCount
FROM dbo.StoryIntelligenceNarrativeCandidateEvents ev
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc
ON nc.NarrativeCandidateID = ev.NarrativeCandidateID
WHERE nc.BookID = @BookID
AND nc.WholeBookPlotSynthesisRunID IS NOT NULL
GROUP BY nc.BookID
),
MetricValueSummary AS
(
SELECT
chapter.BookID,
COUNT_BIG(*) AS MetricValueCount
FROM dbo.SceneMetricValues metric
INNER JOIN dbo.Scenes scene
ON scene.SceneID = metric.SceneID
INNER JOIN dbo.Chapters chapter
ON chapter.ChapterID = scene.ChapterID
WHERE chapter.BookID = @BookID
GROUP BY chapter.BookID
),
CurrentPlotRun AS
(
SELECT TOP (1)
run.BookID,
run.WholeBookPlotSynthesisRunID,
run.Status AS WholeBookPlotSynthesisStatus,
run.ErrorMessage AS WholeBookPlotSynthesisErrorMessage
FROM dbo.WholeBookPlotSynthesisRuns run
WHERE run.BookID = @BookID
AND run.IsCurrent = 1
ORDER BY run.WholeBookPlotSynthesisRunID DESC
)
SELECT
p.StoryIntelligenceBookPipelineID,
p.ProjectID,
project.ProjectName,
p.BookID,
book.BookTitle,
book.Subtitle AS BookSubtitle,
p.CurrentStage,
p.LastCompletedStage,
p.CurrentReviewStage,
p.Status,
p.CompletedUtc,
p.CharacterReviewCompletedUtc,
p.LocationReviewCompletedUtc,
p.AssetReviewCompletedUtc,
p.RelationshipReviewCompletedUtc,
p.KnowledgeReviewCompletedUtc,
p.NarrativeReviewCompletedUtc,
cs.CharacterCandidateGenerationStatus,
CAST(cs.CharacterCandidateCount AS int) AS CharacterCandidateCount,
CAST(cs.CharacterPendingCount AS int) AS CharacterPendingCount,
COALESCE(ls.SignificantCount, 0) AS LocationSignificantCount,
COALESCE(ls.UncertainCount, 0) AS LocationUncertainCount,
cs.LocationCandidateGenerationStatus,
CAST(cs.LocationCandidateCount AS int) AS LocationCandidateCount,
CAST(cs.LocationPendingCount AS int) AS LocationPendingCount,
cs.AssetCandidateGenerationStatus,
CAST(cs.AssetCandidateCount AS int) AS AssetCandidateCount,
CAST(cs.AssetPendingCount AS int) AS AssetPendingCount,
cs.RelationshipCandidateGenerationStatus,
CAST(cs.RelationshipCandidateCount AS int) AS RelationshipCandidateCount,
CAST(cs.RelationshipPendingCount AS int) AS RelationshipPendingCount,
cs.KnowledgeCandidateGenerationStatus,
CAST(cs.KnowledgeCandidateCount AS int) AS KnowledgeCandidateCount,
CAST(cs.KnowledgePendingCount AS int) AS KnowledgePendingCount,
p.LastRunID,
CAST(COALESCE(rs.RunCount, 0) AS int) AS RunCount,
CAST(COALESCE(rs.CompletedRunCount, 0) AS int) AS CompletedRunCount,
CAST(COALESCE(rs.ActiveRunCount, 0) AS int) AS ActiveRunCount,
CAST(COALESCE(rs.FailedRunCount, 0) AS int) AS FailedRunCount,
CAST(COALESCE(rs.TotalDetectedScenes, 0) AS int) AS TotalDetectedScenes,
CAST(COALESCE(rs.CompletedScenes, 0) AS int) AS CompletedScenes,
CAST(COALESCE(rs.FailedScenes, 0) AS int) AS FailedScenes,
plot.WholeBookPlotSynthesisRunID,
plot.WholeBookPlotSynthesisStatus,
plot.WholeBookPlotSynthesisErrorMessage,
CAST(COALESCE(ns.NarrativeCandidateCount, 0) AS int) AS NarrativeCandidateCount,
CAST(COALESCE(ns.NarrativePendingCount, 0) AS int) AS NarrativePendingCount,
CAST(COALESCE(ns.NarrativeThreadCount, 0) AS int) AS NarrativeThreadCount,
CAST(COALESCE(nes.NarrativeEventCount, 0) AS int) AS NarrativeEventCount,
CAST(COALESCE(metrics.MetricValueCount, 0) AS int) AS MetricValueCount
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.Projects project
ON project.ProjectID = p.ProjectID
INNER JOIN dbo.Books book
ON book.BookID = p.BookID
INNER JOIN dbo.ProjectUserAccess access
ON access.ProjectID = p.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
LEFT JOIN RunSummary rs
ON rs.BookID = p.BookID
LEFT JOIN CandidateSummary cs
ON cs.BookID = p.BookID
LEFT JOIN NarrativeSummary ns
ON ns.BookID = p.BookID
LEFT JOIN NarrativeEventSummary nes
ON nes.BookID = p.BookID
LEFT JOIN MetricValueSummary metrics
ON metrics.BookID = p.BookID
LEFT JOIN CurrentPlotRun plot
ON plot.BookID = p.BookID
OUTER APPLY (
SELECT SUM(CASE WHEN TRY_CONVERT(int, JSON_VALUE(c.CandidatePayloadJson, '$.significance.score')) >= 40 THEN 1 ELSE 0 END) AS SignificantCount,
SUM(CASE WHEN c.DecisionStatus = N'Pending' AND
(COALESCE(JSON_VALUE(c.CandidatePayloadJson, '$.confidence'), N'Unknown') <> N'High'
OR JSON_VALUE(c.CandidatePayloadJson, '$.hasParentReviewIssue') = N'true'
OR JSON_VALUE(c.CandidatePayloadJson, '$.category') = N'Ambiguous location') THEN 1 ELSE 0 END) AS UncertainCount
FROM dbo.StoryIntelligenceReviewCandidates c
INNER JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID = c.ReviewCandidateGenerationID
WHERE g.BookID = p.BookID AND g.Module = N'Locations' AND g.IsCurrent = 1 AND g.Status = N'Ready'
) ls
WHERE p.BookID = @BookID
AND book.IsArchived = 0
AND project.IsArchived = 0;
END;
GO

View File

@ -1,209 +0,0 @@
-- Merge duplicate locations and story assets without deleting their archived source records.
-- Keep the selected target details; move canonical references and preserve known names.
CREATE OR ALTER PROCEDURE dbo.Location_Merge
@SourceID int,
@TargetID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @SourceID IS NULL OR @TargetID IS NULL OR @SourceID = @TargetID
THROW 51000, 'Choose two different locations to merge.', 1;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @SourceName nvarchar(200), @TargetName nvarchar(200), @Now datetime2 = SYSUTCDATETIME();
SELECT @ProjectID = ProjectID FROM dbo.Locations WHERE LocationID = @SourceID;
-- Serialize merges in a project before locking individual entities.
DECLARE @LockedProjectID int;
SELECT @LockedProjectID = ProjectID FROM dbo.Projects WITH (UPDLOCK, HOLDLOCK) WHERE ProjectID = @ProjectID;
IF @UserID IS NULL OR NOT EXISTS
(SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1)
THROW 51000, 'You do not have access to this project.', 1;
SELECT @SourceName = LocationName FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK)
WHERE LocationID = @SourceID AND IsArchived = 0;
SELECT @TargetName = LocationName FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK)
WHERE LocationID = @TargetID AND ProjectID = @ProjectID AND IsArchived = 0;
IF @SourceName IS NULL OR @TargetName IS NULL
THROW 51000, 'Both locations must be active and belong to the same project.', 1;
-- Merging an ancestor into its descendant would create a hierarchy cycle.
DECLARE @AncestorID int = @TargetID;
DECLARE @Visited TABLE (LocationID int PRIMARY KEY);
WHILE @AncestorID IS NOT NULL
BEGIN
IF @AncestorID = @SourceID OR EXISTS (SELECT 1 FROM @Visited WHERE LocationID = @AncestorID)
THROW 51000, 'Cannot merge a location into one of its descendants. Choose another location or change its parent first.', 1;
INSERT @Visited VALUES (@AncestorID);
SELECT @AncestorID = ParentLocationID FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK) WHERE LocationID = @AncestorID;
END;
INSERT dbo.LocationAliases (LocationID, Alias, SortOrder)
SELECT @TargetID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.LocationAliases WHERE LocationID = @TargetID), 0)
+ ROW_NUMBER() OVER (ORDER BY AliasValue) * 10
FROM (SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue
UNION SELECT LTRIM(RTRIM(Alias)) FROM dbo.LocationAliases WHERE LocationID = @SourceID) names
WHERE NULLIF(AliasValue, N'') IS NOT NULL AND AliasValue <> @TargetName
AND NOT EXISTS (SELECT 1 FROM dbo.LocationAliases existing WHERE existing.LocationID = @TargetID
AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue));
DELETE sourceRows FROM dbo.WordCompanionSceneLocations sourceRows
WHERE sourceRows.LocationID = @SourceID
AND EXISTS (SELECT 1 FROM dbo.WordCompanionSceneLocations targetRows WHERE targetRows.LocationID = @TargetID
AND targetRows.SceneID = sourceRows.SceneID);
DELETE sourceRows FROM dbo.SceneLocationSuggestions sourceRows
WHERE sourceRows.LocationID = @SourceID AND sourceRows.Status = N'Pending'
AND EXISTS (SELECT 1 FROM dbo.SceneLocationSuggestions targetRows WHERE targetRows.LocationID = @TargetID
AND targetRows.SceneID = sourceRows.SceneID AND targetRows.Status = sourceRows.Status);
UPDATE dbo.Locations SET ParentLocationID = @TargetID WHERE ParentLocationID = @SourceID;
UPDATE dbo.Scenes SET PrimaryLocationID = @TargetID WHERE PrimaryLocationID = @SourceID;
UPDATE dbo.SceneCharacters SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.SceneCharacters SET EntryLocationID = @TargetID WHERE EntryLocationID = @SourceID;
UPDATE dbo.SceneCharacters SET ExitLocationID = @TargetID WHERE ExitLocationID = @SourceID;
UPDATE dbo.StoryAssets SET CurrentLocationID = @TargetID WHERE CurrentLocationID = @SourceID;
UPDATE dbo.SceneAssetLocations SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.SceneFloorPlanOccupancy SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.WordCompanionSceneLocations SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.SceneLocationSuggestions SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.FloorPlans SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.FloorPlanFloors SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.FloorPlanBlocks SET LocationID = @TargetID WHERE LocationID = @SourceID;
UPDATE dbo.FloorPlanTransitions SET FromLocationID = @TargetID WHERE FromLocationID = @SourceID;
UPDATE dbo.FloorPlanTransitions SET ToLocationID = @TargetID WHERE ToLocationID = @SourceID;
UPDATE dbo.CoreImportBasicLocationCandidates SET ExistingLocationID = @TargetID WHERE ExistingLocationID = @SourceID;
UPDATE dbo.CoreImportBasicLocationCandidates SET ParentLocationID = @TargetID WHERE ParentLocationID = @SourceID;
UPDATE dbo.CoreImportBasicLocationCandidates SET ResultLocationID = @TargetID WHERE ResultLocationID = @SourceID;
UPDATE dbo.CoreImportBasicLocationCandidates SET SameAsLocationID = @TargetID WHERE SameAsLocationID = @SourceID;
-- Keep self-links archived with their original endpoints, as in character merging.
UPDATE dbo.LocationRelationships SET IsArchived = 1, UpdatedDate = @Now
WHERE (FromLocationID = @SourceID AND ToLocationID = @TargetID) OR (FromLocationID = @TargetID AND ToLocationID = @SourceID);
UPDATE dbo.LocationRelationships SET FromLocationID = CASE WHEN FromLocationID = @SourceID THEN @TargetID ELSE FromLocationID END,
ToLocationID = CASE WHEN ToLocationID = @SourceID THEN @TargetID ELSE ToLocationID END, UpdatedDate = @Now
WHERE IsArchived = 0 AND (FromLocationID = @SourceID OR ToLocationID = @SourceID);
-- Collapse identical active links while retaining links with different evidence/settings.
UPDATE duplicate SET IsArchived = 1, UpdatedDate = @Now
FROM dbo.LocationRelationships duplicate
WHERE duplicate.IsArchived = 0 AND (duplicate.FromLocationID = @TargetID OR duplicate.ToLocationID = @TargetID)
AND EXISTS (SELECT 1 FROM dbo.LocationRelationships kept WHERE kept.IsArchived = 0
AND kept.LocationRelationshipID < duplicate.LocationRelationshipID
AND kept.FromLocationID = duplicate.FromLocationID AND kept.ToLocationID = duplicate.ToLocationID
AND (kept.LocationRelationshipTypeID = duplicate.LocationRelationshipTypeID OR (kept.LocationRelationshipTypeID IS NULL AND duplicate.LocationRelationshipTypeID IS NULL))
AND (kept.IsBidirectional = duplicate.IsBidirectional OR (kept.IsBidirectional IS NULL AND duplicate.IsBidirectional IS NULL))
AND (kept.Strength = duplicate.Strength OR (kept.Strength IS NULL AND duplicate.Strength IS NULL))
AND (kept.CanHearNormalSpeech = duplicate.CanHearNormalSpeech OR (kept.CanHearNormalSpeech IS NULL AND duplicate.CanHearNormalSpeech IS NULL))
AND (kept.CanHearLoudNoise = duplicate.CanHearLoudNoise OR (kept.CanHearLoudNoise IS NULL AND duplicate.CanHearLoudNoise IS NULL))
AND (kept.CanSeeMovement = duplicate.CanSeeMovement OR (kept.CanSeeMovement IS NULL AND duplicate.CanSeeMovement IS NULL))
AND (kept.CanSeeClearly = duplicate.CanSeeClearly OR (kept.CanSeeClearly IS NULL AND duplicate.CanSeeClearly IS NULL))
AND (kept.CanTravelDirectly = duplicate.CanTravelDirectly OR (kept.CanTravelDirectly IS NULL AND duplicate.CanTravelDirectly IS NULL))
AND (kept.Notes = duplicate.Notes OR (kept.Notes IS NULL AND duplicate.Notes IS NULL)));
UPDATE dbo.Locations
SET IsArchived = 1, ArchivedDate = @Now,
ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (LocationID ', @TargetID, N').'),
UpdatedDate = @Now, ModifiedByUserID = @UserID, ModifiedDateUTC = @Now
WHERE LocationID = @SourceID;
COMMIT TRANSACTION;
SELECT @ProjectID AS ProjectID, @TargetID AS TargetID, @SourceName AS SourceName, @TargetName AS TargetName;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryAsset_Merge
@SourceID int,
@TargetID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @SourceID IS NULL OR @TargetID IS NULL OR @SourceID = @TargetID
THROW 51000, 'Choose two different assets to merge.', 1;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @ProjectID int, @SourceName nvarchar(200), @TargetName nvarchar(200), @Now datetime2 = SYSUTCDATETIME();
SELECT @ProjectID = ProjectID FROM dbo.StoryAssets WHERE StoryAssetID = @SourceID;
-- Serialize merges in a project before locking individual entities.
DECLARE @LockedProjectID int;
SELECT @LockedProjectID = ProjectID FROM dbo.Projects WITH (UPDLOCK, HOLDLOCK) WHERE ProjectID = @ProjectID;
IF @UserID IS NULL OR NOT EXISTS
(SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1)
THROW 51000, 'You do not have access to this project.', 1;
SELECT @SourceName = AssetName FROM dbo.StoryAssets WITH (UPDLOCK, HOLDLOCK)
WHERE StoryAssetID = @SourceID AND IsArchived = 0;
SELECT @TargetName = AssetName FROM dbo.StoryAssets WITH (UPDLOCK, HOLDLOCK)
WHERE StoryAssetID = @TargetID AND ProjectID = @ProjectID AND IsArchived = 0;
IF @SourceName IS NULL OR @TargetName IS NULL
THROW 51000, 'Both assets must be active and belong to the same project.', 1;
INSERT dbo.AssetAliases (StoryAssetID, Alias, SortOrder)
SELECT @TargetID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.AssetAliases WHERE StoryAssetID = @TargetID), 0)
+ ROW_NUMBER() OVER (ORDER BY AliasValue) * 10
FROM (SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue
UNION SELECT LTRIM(RTRIM(Alias)) FROM dbo.AssetAliases WHERE StoryAssetID = @SourceID) names
WHERE NULLIF(AliasValue, N'') IS NOT NULL AND AliasValue <> @TargetName
AND NOT EXISTS (SELECT 1 FROM dbo.AssetAliases existing WHERE existing.StoryAssetID = @TargetID
AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue));
DELETE sourceRows FROM dbo.WordCompanionSceneAssets sourceRows
WHERE sourceRows.StoryAssetID = @SourceID
AND EXISTS (SELECT 1 FROM dbo.WordCompanionSceneAssets targetRows WHERE targetRows.StoryAssetID = @TargetID
AND targetRows.SceneID = sourceRows.SceneID);
DELETE sourceRows FROM dbo.SceneAssetSuggestions sourceRows
WHERE sourceRows.AssetID = @SourceID AND sourceRows.Status = N'Pending'
AND EXISTS (SELECT 1 FROM dbo.SceneAssetSuggestions targetRows WHERE targetRows.AssetID = @TargetID
AND targetRows.SceneID = sourceRows.SceneID AND targetRows.Status = sourceRows.Status);
DELETE sourceRows FROM dbo.SceneFloorPlanOccupancy sourceRows
WHERE sourceRows.StoryAssetID = @SourceID
AND EXISTS (SELECT 1 FROM dbo.SceneFloorPlanOccupancy targetRows WHERE targetRows.StoryAssetID = @TargetID
AND targetRows.SceneID = sourceRows.SceneID);
DELETE sourceRows FROM dbo.ContinuityWarningAcknowledgements sourceRows
WHERE sourceRows.AssetID = @SourceID
AND EXISTS (SELECT 1 FROM dbo.ContinuityWarningAcknowledgements targetRows WHERE targetRows.AssetID = @TargetID
AND targetRows.ProjectID = sourceRows.ProjectID AND targetRows.WarningType = sourceRows.WarningType AND targetRows.SceneIDKey = sourceRows.SceneIDKey AND targetRows.CharacterIDKey = sourceRows.CharacterIDKey);
UPDATE dbo.AssetEvents SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID;
UPDATE dbo.AssetCustodyEvents SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID;
UPDATE dbo.CharacterKnowledge SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID;
UPDATE dbo.SceneAssetLocations SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID;
UPDATE dbo.SceneFloorPlanOccupancy SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID;
UPDATE dbo.WordCompanionSceneAssets SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID;
UPDATE dbo.SceneAssetSuggestions SET AssetID = @TargetID WHERE AssetID = @SourceID;
UPDATE dbo.ContinuityWarningAcknowledgements SET AssetID = @TargetID WHERE AssetID = @SourceID;
-- Keep self-links archived with their original endpoints, as in character merging.
UPDATE dbo.AssetDependencies SET IsArchived = 1, UpdatedDate = @Now
WHERE (SourceAssetID = @SourceID AND TargetAssetID = @TargetID) OR (SourceAssetID = @TargetID AND TargetAssetID = @SourceID);
UPDATE dbo.AssetDependencies SET SourceAssetID = CASE WHEN SourceAssetID = @SourceID THEN @TargetID ELSE SourceAssetID END,
TargetAssetID = CASE WHEN TargetAssetID = @SourceID THEN @TargetID ELSE TargetAssetID END, UpdatedDate = @Now
WHERE IsArchived = 0 AND (SourceAssetID = @SourceID OR TargetAssetID = @SourceID);
-- Collapse identical active links while retaining links with different evidence/settings.
UPDATE duplicate SET IsArchived = 1, UpdatedDate = @Now
FROM dbo.AssetDependencies duplicate
WHERE duplicate.IsArchived = 0 AND (duplicate.SourceAssetID = @TargetID OR duplicate.TargetAssetID = @TargetID)
AND EXISTS (SELECT 1 FROM dbo.AssetDependencies kept WHERE kept.IsArchived = 0
AND kept.AssetDependencyID < duplicate.AssetDependencyID
AND kept.SourceAssetID = duplicate.SourceAssetID AND kept.TargetAssetID = duplicate.TargetAssetID
AND (kept.AssetDependencyTypeID = duplicate.AssetDependencyTypeID OR (kept.AssetDependencyTypeID IS NULL AND duplicate.AssetDependencyTypeID IS NULL))
AND (kept.SourceRequiredStateID = duplicate.SourceRequiredStateID OR (kept.SourceRequiredStateID IS NULL AND duplicate.SourceRequiredStateID IS NULL))
AND (kept.TargetBlockedStateID = duplicate.TargetBlockedStateID OR (kept.TargetBlockedStateID IS NULL AND duplicate.TargetBlockedStateID IS NULL))
AND (kept.Description = duplicate.Description OR (kept.Description IS NULL AND duplicate.Description IS NULL)));
UPDATE dbo.StoryAssets
SET IsArchived = 1, ArchivedDate = @Now,
ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (StoryAssetID ', @TargetID, N').'),
UpdatedDate = @Now, ModifiedByUserID = @UserID, ModifiedDateUTC = @Now
WHERE StoryAssetID = @SourceID;
COMMIT TRANSACTION;
SELECT @ProjectID AS ProjectID, @TargetID AS TargetID, @SourceName AS SourceName, @TargetName AS TargetName;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO

View File

@ -1,24 +0,0 @@
-- Character, location and asset merge services record a Merged activity after
-- moving references. Permit that activity without weakening the existing rules.
SET XACT_ABORT ON;
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
BEGIN TRY
BEGIN TRANSACTION;
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = N'CK_ProjectActivity_ActivityType'
AND parent_object_id = OBJECT_ID(N'dbo.ProjectActivity'))
ALTER TABLE dbo.ProjectActivity DROP CONSTRAINT CK_ProjectActivity_ActivityType;
ALTER TABLE dbo.ProjectActivity WITH CHECK
ADD CONSTRAINT CK_ProjectActivity_ActivityType CHECK
(ActivityType IN (N'Created', N'Updated', N'Deleted', N'Archived', N'Restored',
N'Shared', N'CollaboratorRemoved', N'Exported', N'Merged'));
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO

View File

@ -1,53 +0,0 @@
-- Change only hierarchy fields, preserving location details and aliases.
CREATE OR ALTER PROCEDURE dbo.Location_Reparent
@ProjectID int,
@LocationID int,
@ParentLocationID int,
@ExpectedParentLocationID int = NULL,
@UserID int = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @LockedProjectID int, @OldParentID int, @Name nvarchar(200), @ParentName nvarchar(200);
SELECT @LockedProjectID=ProjectID FROM dbo.Projects WITH (UPDLOCK,HOLDLOCK) WHERE ProjectID=@ProjectID AND IsArchived=0;
IF @LockedProjectID IS NULL OR @UserID IS NULL OR NOT EXISTS
(SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID=@ProjectID AND UserID=@UserID AND IsActive=1)
THROW 51000, 'You do not have access to this project.', 1;
SELECT @Name=LocationName,@OldParentID=ParentLocationID FROM dbo.Locations WITH (UPDLOCK,HOLDLOCK)
WHERE LocationID=@LocationID AND ProjectID=@ProjectID AND IsArchived=0;
SELECT @ParentName=LocationName FROM dbo.Locations WITH (UPDLOCK,HOLDLOCK)
WHERE LocationID=@ParentLocationID AND ProjectID=@ProjectID AND IsArchived=0;
IF @Name IS NULL OR @ParentName IS NULL
THROW 51000, 'Choose active locations from the same project.', 1;
IF ISNULL(@OldParentID,0)<>ISNULL(@ExpectedParentLocationID,0)
THROW 51000, 'The parent has changed since this page was loaded. Review the updated tree and try again.', 1;
IF @OldParentID=@ParentLocationID
THROW 51000, 'This location already has that parent.', 1;
DECLARE @AncestorID int=@ParentLocationID;
DECLARE @Visited TABLE (LocationID int PRIMARY KEY);
WHILE @AncestorID IS NOT NULL
BEGIN
IF @AncestorID=@LocationID OR EXISTS (SELECT 1 FROM @Visited WHERE LocationID=@AncestorID)
THROW 51000, 'A location cannot be its own parent or be moved beneath one of its descendants.', 1;
INSERT @Visited VALUES (@AncestorID);
DECLARE @NextAncestorID int=NULL;
SELECT @NextAncestorID=ParentLocationID FROM dbo.Locations WITH (UPDLOCK,HOLDLOCK) WHERE LocationID=@AncestorID;
SET @AncestorID=@NextAncestorID;
END;
UPDATE dbo.Locations SET ParentLocationID=@ParentLocationID,UpdatedDate=SYSUTCDATETIME(),
ModifiedDateUTC=SYSUTCDATETIME(),ModifiedByUserID=@UserID WHERE LocationID=@LocationID;
DECLARE @Description nvarchar(1000)=CONCAT(N'Parent changed from ',
COALESCE((SELECT LocationName FROM dbo.Locations WHERE LocationID=@OldParentID),N'top level'),N' to ',@ParentName,N'.');
EXEC dbo.ProjectAudit_Record @ProjectID=@ProjectID,@UserID=@UserID,@ActivityType=N'Updated',
@EntityType=N'Location',@EntityID=@LocationID,@EntityName=@Name,@Description=@Description;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO

View File

@ -1,71 +0,0 @@
-- Additional author-facing types and chronological persisted relationship reviews.
SET XACT_ABORT ON;
GO
INSERT dbo.RelationshipTypes(TypeName,IsPermanentDefault,SortOrder,RelationshipCategoryID)
SELECT value.TypeName,0,value.SortOrder,category.RelationshipCategoryID
FROM (VALUES (N'Other',N'Other',900),(N'Acquaintance',N'Friendship',31),(N'Best Friend',N'Friendship',29)) value(TypeName,CategoryName,SortOrder)
JOIN dbo.RelationshipCategories category ON category.CategoryName=value.CategoryName
WHERE NOT EXISTS (SELECT 1 FROM dbo.RelationshipTypes existing WHERE existing.TypeName=value.TypeName);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships
@BookID int, @GenerationID int, @PayloadJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
-- Never replace author decisions or a generation that changed during regeneration.
UPDATE candidate SET CandidatePayloadJson=payload.value,
CandidateType=JSON_VALUE(payload.value,'$.relationshipType'),
SortOrder=CONVERT(int,payload.[key])+1,UpdatedUtc=SYSUTCDATETIME()
FROM dbo.StoryIntelligenceReviewCandidates candidate
JOIN dbo.StoryIntelligenceReviewCandidateGenerations generation
ON generation.ReviewCandidateGenerationID=candidate.ReviewCandidateGenerationID
JOIN OPENJSON(@PayloadJson) payload ON JSON_VALUE(payload.value,'$.key')=candidate.StableKey
WHERE generation.ReviewCandidateGenerationID=@GenerationID AND generation.BookID=@BookID
AND generation.Module=N'Relationships' AND generation.IsCurrent=1 AND generation.Status=N'Ready'
AND candidate.DecisionStatus=N'Pending';
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_ListPage
@BookID int,
@Module nvarchar(40),
@PageNumber int = 1,
@PageSize int = 25
AS
BEGIN
SET NOCOUNT ON;
IF @PageNumber < 1 SET @PageNumber = 1;
IF @PageSize < 1 SET @PageSize = 25;
IF @PageSize > 100 SET @PageSize = 100;
DECLARE @GenerationID int;
SELECT TOP (1) @GenerationID = ReviewCandidateGenerationID
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE BookID = @BookID
AND Module = @Module
AND IsCurrent = 1
AND Status = N'Ready'
ORDER BY ReviewCandidateGenerationID DESC;
;WITH CurrentCandidates AS
(
SELECT candidate.*,
COUNT(*) OVER () AS TotalCount,
SUM(CASE WHEN candidate.DecisionStatus = N'Pending' THEN 1 ELSE 0 END) OVER () AS PendingCount,
ROW_NUMBER() OVER (ORDER BY
CASE WHEN @Module=N'Relationships' THEN TRY_CONVERT(decimal(18,4),JSON_VALUE(candidate.CandidatePayloadJson,'$.firstChapterNumber')) END,
CASE WHEN @Module=N'Relationships' THEN TRY_CONVERT(decimal(18,4),JSON_VALUE(candidate.CandidatePayloadJson,'$.firstSceneNumber')) END,
CASE WHEN @Module=N'Relationships' THEN TRY_CONVERT(int,JSON_VALUE(candidate.CandidatePayloadJson,'$.firstSceneID')) END,
candidate.SortOrder, candidate.ReviewCandidateID) AS RowNumber
FROM dbo.StoryIntelligenceReviewCandidates candidate
WHERE candidate.ReviewCandidateGenerationID = @GenerationID
AND candidate.DecisionStatus = N'Pending'
)
SELECT *
FROM CurrentCandidates
WHERE RowNumber BETWEEN ((@PageNumber - 1) * @PageSize) + 1 AND @PageNumber * @PageSize
ORDER BY RowNumber;
END;
GO

View File

@ -1,92 +0,0 @@
-- Reuse canonical relationships and events. Existing author data is not rewritten.
SET XACT_ABORT ON;
GO
IF COL_LENGTH('dbo.RelationshipEvents','SourceReviewKey') IS NULL
ALTER TABLE dbo.RelationshipEvents ADD SourceReviewKey nvarchar(450) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id=OBJECT_ID('dbo.RelationshipEvents') AND name='UX_RelationshipEvents_ReviewSource')
CREATE UNIQUE INDEX UX_RelationshipEvents_ReviewSource ON dbo.RelationshipEvents(SceneID,SourceReviewKey) WHERE SourceReviewKey IS NOT NULL;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRelationship_AcceptDevelopment
@ProjectID int, @BookID int, @SceneID int, @SourceReviewKey nvarchar(450),
@CharacterAID int, @CharacterBID int, @RelationshipTypeID int, @RelationshipStateID int,
@IsPermanent bit, @IsReciprocal bit, @IsKnownToOtherCharacter bit,
@Intensity int=NULL, @Description nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF NOT EXISTS(SELECT 1 FROM dbo.Scenes s JOIN dbo.Chapters c ON c.ChapterID=s.ChapterID JOIN dbo.Books b ON b.BookID=c.BookID
WHERE s.SceneID=@SceneID AND b.BookID=@BookID AND b.ProjectID=@ProjectID AND s.IsArchived=0 AND c.IsArchived=0 AND b.IsArchived=0)
THROW 53100,'Invalid scene scope.',1;
IF @CharacterAID=@CharacterBID OR (SELECT COUNT(*) FROM dbo.Characters WHERE CharacterID IN (@CharacterAID,@CharacterBID) AND ProjectID=@ProjectID AND IsArchived=0)<>2
THROW 53101,'Invalid relationship participants.',1;
IF @Intensity NOT BETWEEN 1 AND 10 OR NULLIF(LTRIM(RTRIM(@Description)),N'') IS NULL OR NULLIF(@SourceReviewKey,N'') IS NULL
THROW 53102,'Invalid relationship development.',1;
BEGIN TRANSACTION;
DECLARE @LockResult int;
EXEC @LockResult=sys.sp_getapplock @Resource=N'StoryIntelligenceRelationshipAcceptance',@LockMode=N'Exclusive',@LockOwner=N'Transaction',@LockTimeout=15000;
IF @LockResult<0 THROW 53103,'Relationship acceptance is busy. Retry.',1;
DECLARE @RelationshipID int;
SELECT @RelationshipID=CharacterRelationshipID FROM dbo.RelationshipEvents WHERE SceneID=@SceneID AND SourceReviewKey=@SourceReviewKey;
IF @RelationshipID IS NULL
BEGIN
-- Do not flatten factual ties into changing feelings, or reverse a directional track.
SELECT TOP(1) @RelationshipID=CharacterRelationshipID FROM dbo.CharacterRelationships
WHERE ProjectID=@ProjectID AND IsArchived=0 AND EndSceneID IS NULL
AND RelationshipTypeID=@RelationshipTypeID AND IsPermanent=@IsPermanent AND IsReciprocal=@IsReciprocal
AND ((CharacterAID=@CharacterAID AND CharacterBID=@CharacterBID)
OR (@IsReciprocal=1 AND CharacterAID=@CharacterBID AND CharacterBID=@CharacterAID))
ORDER BY CharacterRelationshipID;
IF @RelationshipID IS NULL
BEGIN
INSERT dbo.CharacterRelationships(ProjectID,CharacterAID,CharacterBID,RelationshipTypeID,IsPermanent,StartSceneID,
IsKnownToReader,Notes,IsInitialRelationship,InitialBookID,ReaderInitiallyKnows,IsReciprocal)
VALUES(@ProjectID,@CharacterAID,@CharacterBID,@RelationshipTypeID,@IsPermanent,@SceneID,1,
N'Imported from Story Intelligence scene relationship review.',0,@BookID,0,@IsReciprocal);
SET @RelationshipID=CONVERT(int,SCOPE_IDENTITY());
END;
INSERT dbo.RelationshipEvents(CharacterRelationshipID,SceneID,RelationshipStateID,Intensity,IsKnownToOtherCharacter,Description,SourceReviewKey)
VALUES(@RelationshipID,@SceneID,@RelationshipStateID,@Intensity,@IsKnownToOtherCharacter,@Description,@SourceReviewKey);
-- A later-reviewed earlier scene must move the start of the track back in story order.
UPDATE r SET StartSceneID=@SceneID FROM dbo.CharacterRelationships r
JOIN dbo.Scenes oldScene ON oldScene.SceneID=r.StartSceneID JOIN dbo.Chapters oldChapter ON oldChapter.ChapterID=oldScene.ChapterID
JOIN dbo.Scenes newScene ON newScene.SceneID=@SceneID JOIN dbo.Chapters newChapter ON newChapter.ChapterID=newScene.ChapterID
WHERE r.CharacterRelationshipID=@RelationshipID AND r.IsInitialRelationship=0 AND oldChapter.BookID=@BookID
AND (newChapter.ChapterNumber<oldChapter.ChapterNumber OR (newChapter.ChapterNumber=oldChapter.ChapterNumber AND
(newScene.SceneNumber<oldScene.SceneNumber OR (newScene.SceneNumber=oldScene.SceneNumber AND newScene.SortOrder<oldScene.SortOrder))));
END;
COMMIT;
SELECT @RelationshipID;
END;
GO
-- Replace only pending legacy pair cards with scene proposals; preserve all author decisions.
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships
@BookID int,@GenerationID int,@PayloadJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidateGenerations WITH(UPDLOCK,HOLDLOCK)
WHERE ReviewCandidateGenerationID=@GenerationID AND BookID=@BookID AND Module=N'Relationships' AND IsCurrent=1 AND Status=N'Ready')
THROW 53104,'Review generation changed. Refresh aborted.',1;
-- An accepted/ignored old pair remains an author decision, including its unseen scenes.
SELECT p.value,p.[key] ordinal INTO #Incoming FROM OPENJSON(@PayloadJson) p
WHERE NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidates c WHERE c.ReviewCandidateGenerationID=@GenerationID
AND c.DecisionStatus<>N'Pending' AND (c.StableKey=JSON_VALUE(p.value,'$.key') OR
(ISNULL(TRY_CONVERT(int,JSON_VALUE(c.CandidatePayloadJson,'$.reviewRulesVersion')),0)<3
AND ((JSON_VALUE(c.CandidatePayloadJson,'$.characterAName')=JSON_VALUE(p.value,'$.characterAName') AND JSON_VALUE(c.CandidatePayloadJson,'$.characterBName')=JSON_VALUE(p.value,'$.characterBName'))
OR (JSON_VALUE(c.CandidatePayloadJson,'$.characterAName')=JSON_VALUE(p.value,'$.characterBName') AND JSON_VALUE(c.CandidatePayloadJson,'$.characterBName')=JSON_VALUE(p.value,'$.characterAName'))))));
DELETE FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND DecisionStatus=N'Pending';
INSERT dbo.StoryIntelligenceReviewCandidates(ReviewCandidateGenerationID,ProjectID,BookID,Module,StableKey,CandidateType,DisplayName,SortOrder,ExistingCanonicalID,CandidatePayloadJson)
SELECT @GenerationID,g.ProjectID,@BookID,N'Relationships',JSON_VALUE(p.value,'$.key'),JSON_VALUE(p.value,'$.relationshipType'),
CONCAT(JSON_VALUE(p.value,'$.characterAName'),N' / ',JSON_VALUE(p.value,'$.characterBName')),CONVERT(int,p.ordinal)+1,
TRY_CONVERT(int,JSON_VALUE(p.value,'$.existingRelationshipID')),p.value
FROM #Incoming p JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID=@GenerationID;
UPDATE g SET CandidateCount=(SELECT COUNT(*) FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID),
PendingCount=(SELECT COUNT(*) FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND DecisionStatus=N'Pending'),UpdatedUtc=SYSUTCDATETIME()
FROM dbo.StoryIntelligenceReviewCandidateGenerations g WHERE ReviewCandidateGenerationID=@GenerationID;
COMMIT;
END;
GO

View File

@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models;
@ -257,7 +256,6 @@ public sealed class BookDetailViewModel
public ManuscriptDocumentModel? ManuscriptDocument { get; set; }
public IReadOnlyList<Chapter> Chapters { get; set; } = [];
public StoryIntelligenceBookPipelineState? StoryIntelligencePipeline { get; set; }
public IReadOnlyList<StoryIntelligenceSavedRun> StoryIntelligenceRuns { get; set; } = [];
public StoryIntelligenceBookActionViewModel StoryIntelligenceAction { get; set; } = new();
}
@ -675,11 +673,6 @@ public sealed class CharacterStoryStateViewModel
public sealed class AssetStoryStateViewModel
{
public string Holder { get; set; } = "Unknown";
public string Location { get; set; } = "Unknown";
public string Status { get; set; } = "Unknown";
public int? CustodyEventID { get; set; }
public string? LatestEvent { get; set; }
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public int? LocationID { get; set; }
@ -1259,100 +1252,6 @@ public sealed class TimelineViewModel
public ContinuityValidationSummary? LastValidationSummary { get; set; }
}
public sealed class StoryCalendarViewModel
{
public Project Project { get; set; } = new();
public int? SelectedBookID { get; set; }
public DateOnly? SelectedMonth { get; set; }
public DateOnly? SelectedDate { get; set; }
public DateOnly? EarliestStoryDate { get; set; }
public DateOnly? LatestStoryDate { get; set; }
public DateOnly? PreviousMonth { get; set; }
public DateOnly? NextMonth { get; set; }
public DateOnly? PreviousStoryMonth { get; set; }
public DateOnly? NextStoryMonth { get; set; }
public DateOnly? FirstStoryMonth { get; set; }
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> MonthOptions { get; set; } = [];
public IReadOnlyList<StoryCalendarWeekViewModel> Weeks { get; set; } = [];
public IReadOnlyList<StoryCalendarSceneViewModel> DatedScenes { get; set; } = [];
public IReadOnlyList<StoryCalendarSceneViewModel> UnplacedScenes { get; set; } = [];
public StoryCalendarDayViewModel? SelectedDay { get; set; }
public StoryCalendarDayDetailsViewModel DayDetails => new()
{
ProjectID = Project.ProjectID,
SelectedBookID = SelectedBookID,
SelectedDay = SelectedDay
};
public int SceneCount => DatedScenes.Count;
public int ActiveStoryDayCount => DatedScenes.Select(scene => scene.StoryDate).Distinct().Count();
public bool HasDatedScenes => DatedScenes.Count > 0;
public string SelectedMonthLabel => SelectedMonth?.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("en-GB")) ?? "No dated scenes";
public string StoryRangeLabel
{
get
{
if (EarliestStoryDate.HasValue && LatestStoryDate.HasValue)
{
return EarliestStoryDate == LatestStoryDate
? EarliestStoryDate.Value.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"))
: $"{EarliestStoryDate.Value.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"))} - {LatestStoryDate.Value.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"))}";
}
return "No dated scenes";
}
}
}
public sealed class StoryCalendarDayDetailsViewModel
{
public int ProjectID { get; set; }
public int? SelectedBookID { get; set; }
public StoryCalendarDayViewModel? SelectedDay { get; set; }
public bool ShowBookLabels => !SelectedBookID.HasValue;
}
public sealed class StoryCalendarWeekViewModel
{
public IReadOnlyList<StoryCalendarDayViewModel> Days { get; set; } = [];
}
public sealed class StoryCalendarDayViewModel
{
public DateOnly Date { get; set; }
public int DayNumber => Date.Day;
public bool IsInSelectedMonth { get; set; }
public bool IsSelected { get; set; }
public IReadOnlyList<StoryCalendarSceneViewModel> Scenes { get; set; } = [];
public string DayLabel => Date.ToString("dddd, d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"));
public string QueryValue => Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
public sealed class StoryCalendarSceneViewModel
{
public int SceneID { get; set; }
public int BookID { get; set; }
public string BookTitle { get; set; } = string.Empty;
public int BookNumber { get; set; }
public int ChapterID { get; set; }
public decimal ChapterNumber { get; set; }
public string ChapterTitle { get; set; } = string.Empty;
public decimal SceneNumber { get; set; }
public string Title { get; set; } = string.Empty;
public string? Pov { get; set; }
public DateTime? StartDateTime { get; set; }
public string TimeMode { get; set; } = string.Empty;
public string TimeConfidence { get; set; } = string.Empty;
public string? RelativeTimeText { get; set; }
public string? Summary { get; set; }
public int NarrativeOrder { get; set; }
public DateOnly? StoryDate { get; set; }
public int TimeOfDayRank { get; set; }
public string TimeLabel { get; set; } = string.Empty;
public string SceneLabel => $"Ch {ChapterNumber:g} · Scene {SceneNumber:g}";
public string BookLabel => BookNumber > 0 ? $"Book {BookNumber}" : BookTitle;
}
public sealed class TimelineSettingsViewModel
{
public int ProjectID { get; set; }
@ -1922,7 +1821,6 @@ public sealed class CharacterEditViewModel
public string? CustomSex { get; set; }
[Display(Name = "Birth date")]
[DataType(DataType.Date)]
public DateTime? BirthDate { get; set; }
[Display(Name = "Age at series start")]
@ -1975,8 +1873,6 @@ public sealed class CharacterDetailViewModel
public IReadOnlyList<Character> ProjectCharacters { get; set; } = [];
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
public string DisplayAge { get; set; } = "Unknown";
public string AgeContext { get; set; } = string.Empty;
public Scene? AgeReferenceScene { get; set; }
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeItems { get; set; } = [];
@ -1988,7 +1884,6 @@ public sealed class CharacterDetailViewModel
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RelationshipStateOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public CharacterEnrichmentCharacterStatus CharacterAnalysis { get; set; } = new();
}
public sealed class CharacterMergeViewModel

View File

@ -1,22 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models;
namespace PlotLine.ViewModels;
public sealed class EntityMergeViewModel
{
public Project Project { get; set; } = new();
public string SourceName { get; set; } = string.Empty;
public EntityMergeForm Form { get; set; } = new();
public IReadOnlyList<SelectListItem> TargetOptions { get; set; } = [];
}
public sealed class EntityMergeForm
{
[Range(1, int.MaxValue)]
public int SourceID { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "Choose a record to merge into.")]
public int TargetID { get; set; }
public bool Confirm { get; set; }
}

View File

@ -176,7 +176,6 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; set; } = new();
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; set; } = new();
public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; set; } = new();
public CharacterEnrichmentStatusViewModel CharacterEnrichment { get; set; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
@ -232,25 +231,6 @@ public sealed class WholeBookPlotSynthesisStatusViewModel
public bool IsFailed => string.Equals(Status, WholeBookPlotSynthesisStatuses.Failed, StringComparison.OrdinalIgnoreCase);
}
public sealed class CharacterEnrichmentStatusViewModel
{
public int? CharacterEnrichmentRunID { get; init; }
public string Status { get; init; } = string.Empty;
public int TotalCharacters { get; init; }
public int ProcessedCharacters { get; init; }
public string CurrentStage { get; init; } = string.Empty;
public string CurrentMessage { get; init; } = string.Empty;
public string? ErrorMessage { get; init; }
public bool IsQueued => string.Equals(Status, CharacterEnrichmentStatuses.Queued, StringComparison.OrdinalIgnoreCase);
public bool IsRunning => string.Equals(Status, CharacterEnrichmentStatuses.Running, StringComparison.OrdinalIgnoreCase);
public bool IsCompleted => string.Equals(Status, CharacterEnrichmentStatuses.Completed, StringComparison.OrdinalIgnoreCase);
public bool IsFailed => string.Equals(Status, CharacterEnrichmentStatuses.Failed, StringComparison.OrdinalIgnoreCase);
public bool IsActive => IsQueued || IsRunning;
public int? ProgressPercent => TotalCharacters <= 0
? null
: Math.Clamp(Convert.ToInt32(Math.Round(ProcessedCharacters * 100m / TotalCharacters)), 0, 100);
}
public sealed class StoryIntelligencePipelineDashboardViewModel
{
public int ChaptersAnalysed { get; init; }
@ -465,10 +445,6 @@ public static class StoryIntelligenceCharacterImportActions
public sealed class StoryIntelligenceLocationReviewViewModel
{
public string Scope { get; init; } = "significant";
public int SignificantCount { get; init; }
public int UncertainCount { get; init; }
public int FilteredCount { get; init; }
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
@ -478,9 +454,9 @@ public sealed class StoryIntelligenceLocationReviewViewModel
public int PageSize { get; init; } = 25;
public int TotalCandidateCount { get; init; }
public int PendingCandidateCount { get; init; }
public int PageCount => FilteredCount <= 0 ? 1 : (int)Math.Ceiling(FilteredCount / (double)PageSize);
public int ShowingStart => FilteredCount == 0 || Candidates.Count == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1;
public int ShowingEnd => ShowingStart == 0 ? 0 : Math.Min(FilteredCount, ShowingStart + Candidates.Count - 1);
public int PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize);
public int ShowingStart => PendingCandidateCount == 0 || Candidates.Count == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1;
public int ShowingEnd => ShowingStart == 0 ? 0 : Math.Min(PendingCandidateCount, ShowingStart + Candidates.Count - 1);
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber < PageCount;
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
@ -518,11 +494,6 @@ public sealed class StoryIntelligenceLocationTargetOptionViewModel
public sealed class StoryIntelligenceLocationReviewCandidateViewModel
{
public PlotLine.Services.LocationSignificanceAssessment? Significance { get; init; }
public int HierarchyDepth { get; set; }
public string DecisionStatus { get; set; } = "Pending";
public bool IsPending => string.Equals(DecisionStatus, "Pending", StringComparison.OrdinalIgnoreCase);
public bool NeedsReview => HasParentReviewIssue || !string.Equals(Confidence, "High", StringComparison.OrdinalIgnoreCase) || Category == "Ambiguous location";
public string Key { get; init; } = string.Empty;
public string LocationName { get; init; } = string.Empty;
public string LeafLocationName { get; init; } = string.Empty;
@ -550,7 +521,6 @@ public sealed class StoryIntelligenceLocationReviewCandidateViewModel
public sealed class StoryIntelligenceLocationImportForm
{
public string Scope { get; set; } = "significant";
public Guid BatchID { get; set; }
public int PageNumber { get; set; } = 1;
public List<StoryIntelligenceLocationImportChoiceForm> Locations { get; set; } = [];
@ -662,25 +632,12 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<SelectListItem> RelationshipStateOptions { get; init; } = [];
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel
{
public string ProposalKind { get; init; } = "Development";
public string RelationshipState { get; init; } = "Unknown";
public int RelationshipStateID { get; init; }
public bool IsReciprocal { get; init; }
public bool IsKnownToOtherCharacter { get; init; }
public int? Intensity { get; init; }
public string? Development { get; init; }
public bool NeedsDirectionReview { get; init; }
public int ReviewRulesVersion { get; init; }
public decimal? FirstChapterNumber { get; init; }
public decimal? FirstSceneNumber { get; init; }
public int? FirstSceneID { get; init; }
public string Key { get; init; } = string.Empty;
public string CharacterAName { get; init; } = string.Empty;
public string CharacterBName { get; init; } = string.Empty;
@ -706,14 +663,6 @@ public sealed class StoryIntelligenceRelationshipImportForm
public sealed class StoryIntelligenceRelationshipImportChoiceForm
{
[Microsoft.AspNetCore.Mvc.ModelBinding.BindNever]
public int? AcceptedRelationshipID { get; set; }
public bool ReverseDirection { get; set; }
public int? RelationshipStateID { get; set; }
public bool? IsReciprocal { get; set; }
public bool? IsKnownToOtherCharacter { get; set; }
public int? Intensity { get; set; }
public string? Development { get; set; }
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
public int? RelationshipTypeID { get; set; }

View File

@ -50,7 +50,6 @@ public sealed class StoryIntelligenceGlobalProgressBookViewModel
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
public long TotalDurationMs { get; init; }
public DateTime UpdatedUtc { get; init; }
public IReadOnlyList<StoryIntelligenceGlobalProgressRunViewModel> Runs { get; init; } = [];
public bool HasActiveRuns => PendingRunCount > 0
|| RunningRunCount > 0
@ -87,22 +86,3 @@ public sealed class StoryIntelligenceGlobalProgressBookViewModel
return $"{Math.Max(1, (int)Math.Round(remaining.TotalSeconds)):N0} sec";
}
}
public sealed class StoryIntelligenceGlobalProgressRunViewModel
{
public int RunID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string BookTitle { get; init; } = "Book";
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
public int TotalDetectedScenes { get; init; }
public int CompletedScenes { get; init; }
public int FailedScenes { get; init; }
public long TotalDurationMs { get; init; }
public DateTime UpdatedUtc { get; init; }
public bool IsTerminal => Status is StoryIntelligenceRunStatuses.Completed
or StoryIntelligenceRunStatuses.CompletedWithWarnings
or StoryIntelligenceRunStatuses.Failed
or StoryIntelligenceRunStatuses.Cancelled;
}

View File

@ -1,35 +0,0 @@
using PlotLine.Models;
using PlotLine.Services;
namespace PlotLine.ViewModels;
public sealed class StoryPointViewModel
{
public Project Project { get; init; } = new();
public IReadOnlyList<Book> Books { get; init; } = [];
public IReadOnlyList<Chapter> Chapters { get; init; } = [];
public IReadOnlyList<Scene> Scenes { get; init; } = [];
public StoryPointSnapshot? Snapshot { get; init; }
}
public sealed class StoryPointSnapshot
{
public int ProjectID { get; init; }
public Book Book { get; init; } = new();
public Chapter Chapter { get; init; } = new();
public Scene Scene { get; init; } = new();
public string? PovName { get; init; }
public IReadOnlyList<StoryPointCharacter> Characters { get; init; } = [];
public IReadOnlyList<RelationshipMapRelationshipViewModel> Relationships { get; init; } = [];
public IReadOnlyList<AssetStoryStateViewModel> Assets { get; init; } = [];
public LocationStoryStateViewModel? Location { get; init; }
public StoryPointSource? PreviousLocationUse { get; init; }
public IReadOnlyList<StoryPointThread> Threads { get; init; } = [];
public IReadOnlyList<ContinuityWarningViewModel> Warnings { get; init; } = [];
public IReadOnlyDictionary<int, StoryPointSource> Sources { get; init; } = new Dictionary<int, StoryPointSource>();
}
public sealed record StoryPointSource(int SceneID, string Label);
public sealed record StoryPointCharacter(SceneCharacter Appearance, CharacterAgeDisplayResult Age,
CharacterStoryStateViewModel? State, StoryPointSource? PreviousAppearance, IReadOnlyList<CharacterKnowledgeItem> Knowledge);
public sealed record StoryPointThread(int PlotThreadID, string Title, string Type, string PlotLineName, StoryPointSource? Source, string State);

View File

@ -19,16 +19,6 @@
.OrderBy(name => !string.Equals(name, Model.Character.CharacterName, StringComparison.OrdinalIgnoreCase))
.ThenBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
var analysis = Model.CharacterAnalysis;
var analysisStatus = string.IsNullOrWhiteSpace(analysis.Status) ? "Not analysed" : analysis.Status;
var analysisActive = analysis.Status is CharacterEnrichmentStatuses.Queued or CharacterEnrichmentStatuses.Running;
var analysisButtonText = analysis.Status switch
{
CharacterEnrichmentStatuses.Queued => "Queued...",
CharacterEnrichmentStatuses.Running => "Analysing...",
_ => "Reanalyse character"
};
var lastAnalysed = analysis.LastAnalysedUtc?.ToLocalTime().ToString("d MMMM yyyy, HH:mm");
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -57,10 +47,6 @@
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Character.CharacterID">Edit character</a>
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Character.CharacterID">Merge with another character</a>
<form asp-action="Reanalyse" method="post" data-character-reanalyse-form>
<input type="hidden" name="id" value="@Model.Character.CharacterID" />
<button class="dropdown-item" type="submit" data-character-reanalyse-button @(analysisActive ? "disabled" : string.Empty)>@analysisButtonText</button>
</form>
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Export character</a>
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID" asp-route-format="markdown">Markdown</a>
<div class="dropdown-divider"></div>
@ -101,41 +87,14 @@
}
<section id="character-overview" class="asset-detail-grid">
<article class="plain-card" data-character-analysis-panel data-character-id="@Model.Character.CharacterID" data-book-id="@analysis.BookID">
<article class="plain-card">
<h2>Profile</h2>
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
<p><strong>Sex:</strong> @Model.Character.Sex</p>
<p><strong>Date of birth:</strong> <span data-character-birth-date>@(Model.Character.BirthDate?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB")) ?? "Not recorded")</span></p>
<p><strong>Age:</strong> <span data-character-profile-age>@Model.DisplayAge</span>
<small class="d-block muted">@Model.AgeContext
@if (Model.AgeReferenceScene is { } ageScene)
{
<a asp-controller="Scenes" asp-action="Edit" asp-route-id="@ageScene.SceneID">@ageScene.SceneTitle · @ageScene.StartDateTime?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB"))</a>
}
</small>
</p>
<p><strong>Age:</strong> @Model.DisplayAge</p>
<p><strong>Height:</strong> @Model.Character.Height</p>
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
<p><strong>Story importance:</strong> @storyImportance</p>
<hr />
<p><strong>Character analysis:</strong> <span data-character-analysis-status>@analysisStatus</span></p>
<p><strong>Last analysed:</strong> <span data-character-analysis-last>@(lastAnalysed ?? "Not analysed")</span></p>
@if (!string.IsNullOrWhiteSpace(analysis.CurrentMessage))
{
<p class="muted" data-character-analysis-message>@analysis.CurrentMessage</p>
}
else
{
<p class="muted" data-character-analysis-message></p>
}
</article>
<article class="plain-card" data-character-generated-analysis>
<h2>Story Intelligence Analysis</h2>
<p class="@(string.IsNullOrWhiteSpace(analysis.Summary) ? "muted" : string.Empty)" data-character-analysis-summary>@(analysis.Summary ?? "No generated character analysis yet.")</p>
<p><strong>Date of birth:</strong> <span data-character-analysis-dob>@(analysis.DateOfBirth ?? "Unknown")</span></p>
<p><strong>Age at start of series:</strong> <span data-character-analysis-age>@(analysis.AgeAtStartOfSeries ?? "Unknown")</span></p>
<p><strong>Height:</strong> <span data-character-analysis-height>@(analysis.Height ?? "Unknown")</span></p>
<p><strong>Eye colour:</strong> <span data-character-analysis-eye>@(analysis.EyeColour ?? "Unknown")</span></p>
</article>
<article class="plain-card">
<h2>Known names</h2>
@ -569,7 +528,6 @@
@section Scripts {
<script src="~/js/character-enrichment-detail.js" asp-append-version="true"></script>
<script>
(() => {
document.querySelectorAll("[data-relationship-editor]").forEach((editor) => {

View File

@ -60,9 +60,7 @@
</div>
<div class="col-md-4">
<label asp-for="BirthDate" class="form-label"></label>
<input asp-for="BirthDate" type="date" asp-format="{0:yyyy-MM-dd}" class="form-control" />
<span asp-validation-for="BirthDate" class="text-danger"></span>
<div class="form-text">Optional. Enter a date only; no birth time is needed.</div>
<input asp-for="BirthDate" class="form-control" />
</div>
<div class="col-md-4">
<label asp-for="AgeAtSeriesStart" class="form-label"></label>

View File

@ -30,20 +30,13 @@
<p class="muted">@Model.Location.LocationPath</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Edit" asp-route-id="@Model.Location.LocationID">Edit</a>
<a class="btn btn-outline-primary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">All locations</a>
<div class="dropdown pw-actions">
<button class="btn pw-actions__toggle dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Tools</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Location.LocationID">Edit location</a>
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Location.LocationID">Merge with another location</a>
<div class="dropdown-divider"></div>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this location?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@Model.Location.LocationID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="dropdown-item text-danger" type="submit">Archive Location</button>
</form>
</div>
</div>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this location?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@Model.Location.LocationID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-danger" type="submit">Archive Location</button>
</form>
</div>
</div>

View File

@ -21,44 +21,15 @@
<a class="btn btn-primary" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">New location</a>
</div>
@if (TempData["LocationMessage"] is string message)
{
<div class="alert alert-success" role="status">@message</div>
}
@if (TempData["LocationError"] is string error)
{
<div class="alert alert-danger" role="alert">@error</div>
}
<div class="edit-panel">
<label for="locationDragAction" class="form-label">Drag a location onto another location to:</label>
<select id="locationDragAction" class="form-select" data-location-drag-action>
<option value="parent">Change its parent</option>
<option value="relationship">Create a relationship</option>
</select>
<p class="muted mt-2 mb-0">Drop onto the new parent, then confirm the move. Child locations move with it. You can also change the parent using Edit.</p>
<p class="text-danger mb-0" role="alert" data-location-move-error></p>
<p class="mb-0" role="status" data-location-move-status></p>
</div>
<form asp-action="Reparent" method="post" data-location-reparent-form
data-confirm-message="Change location parent?" data-confirm-button-text="Move location" data-confirm-button-class="btn-primary">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="locationId" />
<input type="hidden" name="parentLocationId" />
<input type="hidden" name="expectedParentLocationId" />
</form>
<div class="modal fade" tabindex="-1" aria-labelledby="locationRelationshipTitle" aria-hidden="true" data-location-relationship-modal>
<div class="modal-dialog modal-dialog-centered modal-lg">
<section class="modal-content p-3 d-none" data-drag-drop-panel>
<section class="drag-drop-panel d-none" data-drag-drop-panel>
<div class="drag-drop-panel-header">
<div>
<p class="eyebrow" data-drag-panel-kicker>Location relationship</p>
<h2 id="locationRelationshipTitle" data-drag-panel-title>Create location relationship</h2>
<h2 data-drag-panel-title>Create location relationship</h2>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-drag-cancel>Cancel</button>
</div>
<p class="muted" data-drag-panel-summary></p>
<p class="text-danger" role="alert" data-location-relationship-error></p>
<form asp-controller="Locations" asp-action="DropRelationship" method="post" class="drag-drop-form" data-drag-form="location-relationship">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="FromLocationID" />
@ -81,8 +52,6 @@
</div>
</form>
</section>
</div>
</div>
@if (!Model.Locations.Any())
{
@ -93,6 +62,28 @@
}
else
{
<partial name="_LocationTree" model="Model" />
<section class="edit-panel location-tree">
@foreach (var location in Model.Locations)
{
<div class="location-tree-row" draggable="true" data-drag-type="location" data-drag-id="@location.LocationID" data-drag-label="@location.LocationName" style="--location-depth:@location.Depth">
<div>
<a asp-action="Details" asp-route-id="@location.LocationID"><strong>@location.LocationName</strong></a>
@if (!string.IsNullOrWhiteSpace(location.LocationTypeName))
{
<span class="location-badge">@location.LocationTypeName</span>
}
<span class="muted">@location.LocationPath</span>
</div>
<div class="button-row compact-buttons">
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@location.LocationID">Edit</a>
<form asp-action="Archive" method="post" data-confirm-message="Archive this location?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@location.LocationID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
</form>
</div>
</div>
}
</section>
}
</div>

View File

@ -1,53 +0,0 @@
@model EntityMergeViewModel
@{
ViewData["Title"] = $"Merge {Model.SourceName}";
ViewData["ProjectSection"] = "Locations";
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>
<a asp-action="Details" asp-route-id="@Model.Form.SourceID">@Model.SourceName</a>
<span>Merge</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading compact">
<div>
<p class="eyebrow">Location identity</p>
<h1>Merge @Model.SourceName</h1>
<p class="lead-text">Known names, scene references, child locations, relationships and floor plan links move to the selected location. The source location is archived. The selected location keeps its details. A location cannot be merged into one of its descendants.</p>
</div>
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.Form.SourceID">Cancel</a>
</div>
@if (!Model.TargetOptions.Any())
{
<div class="alert alert-info">There are no other active locations in this project to merge with.</div>
}
<form asp-action="Merge" method="post" class="edit-panel">
<input asp-for="Form.SourceID" type="hidden" />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="row g-3">
<div class="col-md-8">
<label asp-for="Form.TargetID" class="form-label">Canonical location</label>
<select asp-for="Form.TargetID" asp-items="Model.TargetOptions" class="form-select">
<option value="">Choose location</option>
</select>
</div>
<div class="col-12">
<label class="form-check">
<input asp-for="Form.Confirm" class="form-check-input" />
<span class="form-check-label">I understand this will archive @Model.SourceName and move references to the selected location.</span>
</label>
</div>
</div>
<button disabled="@(!Model.TargetOptions.Any())" class="btn btn-danger mt-3" type="submit">Merge location</button>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,24 +0,0 @@
@model LocationListViewModel
<section class="edit-panel location-tree" data-location-tree>
@foreach (var location in Model.Locations)
{
<div class="location-tree-row" draggable="true" data-drag-type="location" data-drag-id="@location.LocationID" data-drag-label="@location.LocationName" data-parent-location-id="@location.ParentLocationID" data-parent-location-name="@location.ParentLocationName" style="--location-depth:@location.Depth">
<div>
<a asp-action="Details" asp-route-id="@location.LocationID"><strong>@location.LocationName</strong></a>
@if (!string.IsNullOrWhiteSpace(location.LocationTypeName))
{
<span class="location-badge">@location.LocationTypeName</span>
}
<span class="muted">@location.LocationPath</span>
</div>
<div class="button-row compact-buttons">
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@location.LocationID">Edit</a>
<form asp-action="Archive" method="post" data-confirm-message="Archive this location?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@location.LocationID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
</form>
</div>
</div>
}
</section>

View File

@ -9,7 +9,7 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p>
<h1 id="story-location-title">Review Locations</h1>
<p>Explore important story settings first. Resolve uncertain identities and use the full list to inspect or organise every detected place.</p>
<p>Review place suggestions that need a create, same-location, or ignore decision.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -21,18 +21,12 @@
<div class="alert alert-success">@message</div>
}
<nav class="onboarding-actions mb-3" aria-label="Location filters">
<a class="btn @(Model.LocationReview.Scope == "significant" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="significant">Significant locations (@Model.LocationReview.SignificantCount)</a>
<a class="btn @(Model.LocationReview.Scope == "all" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="all">All detected locations (@Model.LocationReview.TotalCandidateCount)</a>
<a class="btn @(Model.LocationReview.Scope == "uncertain" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="uncertain">Need review (@Model.LocationReview.UncertainCount)</a>
</nav>
<p class="text-muted">Parent locations are included for context. Confidence describes identification; significance describes story importance.</p>
<section class="story-review-chapter-list" aria-label="Location review">
@if (Model.LocationReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No locations match this filter.</strong>
<p class="mb-0">Choose All detected locations to explore the complete list.</p>
<strong>No location decisions are waiting.</strong>
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
@ -46,13 +40,12 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
<span class="story-review-page-status">
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
&middot; Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.FilteredCount.ToString("N0") locations
&middot; Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
</span>
</div>
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<input type="hidden" name="Scope" value="@Model.LocationReview.Scope" />
<input type="hidden" name="PageNumber" value="@Model.LocationReview.PageNumber" />
<div class="story-character-card-grid">
@for (var i = 0; i < locationCandidates.Count; i++)
@ -66,32 +59,18 @@
? $"candidate:{candidate.ProposedParentCandidateKey}"
: string.Empty;
var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint, candidate.ProposedParentLocationName });
<details class="story-character-card" style="margin-inline-start: @(Math.Min(candidate.HierarchyDepth, 4) * 16)px" data-location-card data-location-status="@status" data-location-existing-match="@candidate.IsExistingMatch.ToString().ToLowerInvariant()" data-location-search-text="@searchText">
<details class="story-character-card" open data-location-card data-location-status="@status" data-location-existing-match="@candidate.IsExistingMatch.ToString().ToLowerInvariant()" data-location-search-text="@searchText">
<summary class="story-review-card-header">
<span>
@if (!string.IsNullOrWhiteSpace(proposedParentLabel)) { <small>Within @proposedParentLabel</small> }
<strong>@candidate.LocationName</strong>
<small>@(candidate.Significance?.Classification ?? "Not yet assessed") · @(candidate.IsPending && candidate.NeedsReview ? "Identity needs review" : "")</small>
<small>@candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>Confidence: @candidate.Confidence</em>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Locations[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
@if (candidate.Significance is { } significance)
{
<div class="story-review-note">
<strong>@significance.Classification · significance @significance.Score/100</strong>
<ul>@foreach (var reason in significance.Reasons) { <li>@reason</li> }</ul>
@if (significance.Characters.Any()) { <p>Characters: @string.Join(", ", significance.Characters)</p> }
@if (significance.PlotLines.Any()) { <p>Plot lines: @string.Join(", ", significance.PlotLines)</p> }
@foreach (var scene in significance.KeyScenes) {
<a asp-controller="Scenes" asp-action="Details" asp-route-id="@scene.SceneID">@scene.SceneLabel</a><br />
}
</div>
}
<dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Proposed parent</dt><dd>@Display(proposedParentLabel)</dd></div>
@ -119,10 +98,6 @@
</details>
}
@if (candidate.IsPending)
{
<input type="hidden" name="Locations.Index" value="@i" />
<input type="hidden" name="Locations[@i].Key" value="@candidate.Key" />
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
@ -215,8 +190,6 @@
<input type="hidden" name="Locations[@i].AliasTargetKey" data-location-alias-target />
</div>
}
}
else { <p class="text-muted">Decision saved: @candidate.DecisionStatus</p> }
</div>
</details>
}
@ -225,21 +198,20 @@
<div class="onboarding-actions">
<span class="text-muted">
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
&middot; Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.FilteredCount.ToString("N0") locations
&middot; Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
</span>
@if (Model.LocationReview.HasPreviousPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="@Model.LocationReview.Scope" asp-route-page="@(Model.LocationReview.PageNumber - 1)">Previous</a>
<button class="btn btn-outline-secondary" type="submit" name="intent" value="previous" data-busy-label="Saving page...">Previous</button>
}
@if (Model.LocationReview.HasNextPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="@Model.LocationReview.Scope" asp-route-page="@(Model.LocationReview.PageNumber + 1)">Next</a>
<button class="btn btn-primary" type="submit" name="intent" value="next" data-busy-label="Saving page...">Next</button>
}
@if (Model.LocationReview.CanImport)
else
{
<button class="btn btn-primary" type="submit" name="intent" value="save" data-busy-label="Saving locations...">Save location decisions</button>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
</div>
</form>
}

View File

@ -11,7 +11,7 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p>
<h1 id="story-relationship-title">Review Relationships</h1>
<p>Review meaningful relationship facts, states and developments scene by scene. Direction runs from Character A to Character B unless marked reciprocal. Accepted proposals appear in the existing scene relationship timeline.</p>
<p>Review relationship suggestions by character pair and decide what should become canonical.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -45,7 +45,8 @@
{
<span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.PendingCandidateCount.ToString("N0") pending</span>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Accept all on this page</button>
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button>
@ -57,16 +58,11 @@
@for (var i = 0; i < relationshipCandidates.Count; i++)
{
var candidate = relationshipCandidates[i];
@if (candidate.FirstChapterNumber.HasValue && (i == 0 || candidate.FirstChapterNumber != relationshipCandidates[i - 1].FirstChapterNumber))
{
<h2 class="h5 mt-3">Chapter @candidate.FirstChapterNumber.Value.ToString("0.##")</h2>
}
<details class="story-character-card" open data-relationship-card>
<summary class="story-review-card-header">
<span>
<small>@Display(candidate.FirstAppearance)</small>
<strong>@candidate.CharacterAName / @candidate.CharacterBName</strong>
<small>@candidate.ProposalKind — @candidate.RelationshipState</small>
<small>@candidate.RelationshipType - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>@candidate.Confidence</em>
</summary>
@ -78,14 +74,14 @@
<div><dt>Character A</dt><dd>@candidate.CharacterAName</dd></div>
<div><dt>Character B</dt><dd>@candidate.CharacterBName</dd></div>
<div><dt>First seen</dt><dd>@Display(candidate.FirstAppearance)</dd></div>
<div><dt>Direction</dt><dd>@candidate.CharacterAName @(candidate.IsReciprocal ? "↔" : "→") @candidate.CharacterBName</dd></div>
<div><dt>Last seen</dt><dd>@Display(candidate.LastAppearance)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingRelationshipLabel ?? "None")</dd></div>
</dl>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleScene))
{
<div class="story-review-note">
<strong>Scene</strong>
<strong>Example scene</strong>
<p>@candidate.ExampleScene</p>
</div>
}
@ -98,47 +94,62 @@
</div>
}
@if (candidate.NeedsDirectionReview)
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">This stored analysis predates explicit direction. Check which character feels or acts towards the other and whether the state is mutual.</div>
<div class="story-review-note">
<strong>Possible existing relationship</strong>
<p>@candidate.CharacterAName and @candidate.CharacterBName may already have a relationship in PlotDirector. Choose whether to link them or create a separate relationship.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision for this scene proposal</legend>
<label><input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action /> Accept development / fact</label>
<label><input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action /> Ignore this proposal</label>
<legend>Decision</legend>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action />
Create new relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.LinkExisting" data-relationship-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Link existing relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Alias" data-relationship-action />
Merge with another relationship
</label>
<label>
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action />
Ignore
</label>
</fieldset>
<div data-relationship-type-panel>
<label class="form-label" for="relationship-type-@i">@(candidate.ProposalKind == "Factual" ? "Factual relationship type" : "Relationship track (Other allows changing states)")</label>
<label class="form-label" for="relationship-type-@i">Relationship type</label>
<select id="relationship-type-@i" class="form-select" name="Relationships[@i].RelationshipTypeID">
@foreach (var option in Model.RelationshipReview.RelationshipTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == candidate.RelationshipTypeID.ToString())">@option.Text</option>
}
</select>
<label class="form-label mt-2" for="relationship-state-@i">State in this scene</label>
<select id="relationship-state-@i" class="form-select" name="Relationships[@i].RelationshipStateID">
@foreach (var option in Model.RelationshipReview.RelationshipStateOptions)
<div class="story-review-note mt-2">
<strong>Story Intelligence observation</strong>
<p>@candidate.RelationshipType</p>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<option value="@option.Value" selected="@(option.Value == candidate.RelationshipStateID.ToString())">@option.Text</option>
<strong>Evidence</strong>
<p>@candidate.ExampleContext</p>
}
</div>
</div>
<div data-relationship-alias-panel hidden>
<label class="form-label" for="relationship-alias-target-@i">Merge target</label>
<select id="relationship-alias-target-@i" class="form-select" name="Relationships[@i].AliasTargetKey" data-relationship-alias-target>
<option value="">Choose relationship...</option>
@foreach (var target in relationshipCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.CharacterAName / @target.RelationshipType / @target.CharacterBName@(target.IsExistingMatch ? $" -> {target.ExistingRelationshipLabel}" : string.Empty)</option>
}
</select>
<label class="form-label mt-2" for="relationship-direction-@i">Direction</label>
<select id="relationship-direction-@i" class="form-select" name="Relationships[@i].IsReciprocal">
<option value="false" selected="@(!candidate.IsReciprocal)">@candidate.CharacterAName → @candidate.CharacterBName</option>
<option value="true" selected="@candidate.IsReciprocal">Mutual / reciprocal</option>
</select>
<label class="mt-2"><input type="checkbox" name="Relationships[@i].ReverseDirection" value="true" /> Reverse A and B (also reverses who knows)</label>
<label class="form-label mt-2" for="relationship-known-@i">Does @candidate.CharacterBName know this feeling / state?</label>
<select id="relationship-known-@i" class="form-select" name="Relationships[@i].IsKnownToOtherCharacter">
<option value="false" selected="@(!candidate.IsKnownToOtherCharacter)">No / not established</option>
<option value="true" selected="@candidate.IsKnownToOtherCharacter">Yes</option>
</select>
<label class="form-label mt-2" for="relationship-intensity-@i">Emotional intensity (1–10; blank if unknown)</label>
<input id="relationship-intensity-@i" class="form-control" type="number" min="1" max="10" name="Relationships[@i].Intensity" value="@candidate.Intensity" />
<label class="form-label mt-2" for="relationship-development-@i">Development / factual discovery</label>
<textarea id="relationship-development-@i" class="form-control" name="Relationships[@i].Development" rows="3">@candidate.Development</textarea>
<p class="text-muted">Acceptance reuses a matching factual or directional relationship and adds this scene event. Existing history is preserved.</p>
</div>
</div>
</details>

View File

@ -1,17 +1,9 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Story Intelligence Review Centre";
var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID));
var progressPercent = ProgressPercent(Model);
}
<section class="story-review-centre-shell"
aria-labelledby="story-review-centre-title"
data-story-intelligence-onboarding
data-story-intelligence-batch-id="@Model.BatchID"
data-story-intelligence-run-ids="@runIds"
data-story-max-progress-percent="@progressPercent"
data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
<section class="story-review-centre-shell" aria-labelledby="story-review-centre-title">
<div class="story-review-centre-workspace">
<div class="story-review-centre-heading">
<p class="eyebrow">Story Intelligence</p>
@ -31,8 +23,18 @@
}
<section class="story-review-centre-grid" aria-label="Story Intelligence review categories">
@* "Characters" "Review Characters" "StoryIntelligenceCharacters" "View Characters" new { projectId = Model.ProjectID } *@
@ReviewCharacters()
@ReviewModule(
"Characters",
Model.CharacterNeedsAttentionCount,
Model.PipelineDashboard.CharacterStageComplete,
false,
"Resolve character identities before reviewing dependent Story Intelligence.",
"Review Characters",
"StoryIntelligenceCharacters",
"View Characters",
"Characters",
new { projectId = Model.ProjectID },
true)
@ReviewModule(
"Assets",
@ -58,16 +60,17 @@
"StoryBible",
new { projectId = Model.ProjectID })
<article class="story-review-centre-tile">
<h2>Locations</h2>
<strong class="story-review-centre-tile__status story-review-centre-tile__status--ready">@Model.LocationReview.TotalCandidateCount locations identified</strong>
<p>@Model.LocationReview.SignificantCount significant · @Model.LocationReview.UncertainCount need review</p>
<p>Explore story settings. Review uncertain identities when your judgement is needed.</p>
@if (Model.CharacterGateSatisfied) {
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Explore Locations</a>
@if (Model.PipelineDashboard.LocationStageComplete) { <a class="btn btn-outline-secondary btn-sm" asp-controller="Locations" asp-action="Index" asp-route-projectId="@Model.ProjectID">View Locations</a> }
} else { <span class="btn btn-outline-secondary btn-sm disabled" aria-disabled="true">Locked</span> }
</article>
@ReviewModule(
"Locations",
Model.LocationNeedsAttentionCount,
Model.PipelineDashboard.LocationStageComplete,
!Model.CharacterGateSatisfied,
"Places found in the manuscript.",
"Review Locations",
"StoryIntelligenceLocations",
"View Locations",
"Locations",
new { projectId = Model.ProjectID })
@ReviewPlotSynthesis()
@ -101,32 +104,6 @@
}
</section>
<div class="story-run-state" aria-hidden="true" hidden>
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
<div data-story-run-id="@chapter.RunID"
data-story-chapter-title="@chapter.ChapterTitle"
data-story-raw-status="@chapter.Status"
data-story-chapter-number="@chapter.ChapterNumber"
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
data-story-current-scene-id="@(chapter.CurrentSceneID?.ToString() ?? string.Empty)"
data-story-current-scene-number="@(chapter.CurrentSceneNumber?.ToString() ?? string.Empty)"
data-story-current-scene-title="@(chapter.CurrentSceneDisplayTitle ?? string.Empty)"
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)"
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
<span data-story-run-status>@chapter.Status</span>
<span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-message>@(chapter.CurrentMessage ?? string.Empty)</span>
<span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span>
<span data-story-run-current-scene-title>@(chapter.CurrentSceneDisplayTitle ?? string.Empty)</span>
<span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span>
</div>
}
</div>
<div class="onboarding-actions story-review-centre-actions">
<a class="btn btn-outline-secondary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to Book</a>
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
@ -195,78 +172,17 @@
}
return new Microsoft.AspNetCore.Html.HtmlString($$"""
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}" data-plot-synthesis-tile data-book-id="{{Model.BookID}}">
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}" data-plot-synthesis-tile>
<div>
<h2>Plot Lines & Threads</h2>
<p>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
</div>
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
{{ProgressHtml(Model.PlotSynthesis.IsRunning || Model.PlotSynthesis.IsQueued, null, Model.PlotSynthesis.IsQueued ? "Preparing manuscript..." : "Analysing plot structure...", null)}}
{{actionHtml}}
</article>
""");
}
private Microsoft.AspNetCore.Html.IHtmlContent ReviewCharacters()
{
var reviewComplete = Model.CharacterNeedsAttentionCount.GetValueOrDefault() == 0 && Model.PipelineDashboard.CharacterStageComplete;
var enrichment = Model.CharacterEnrichment;
var status = "Ready for review";
var statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
var description = "Resolve character identities before reviewing dependent Story Intelligence.";
var actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(Url.Action("StoryIntelligenceCharacters", "Onboarding", new { batchId = Model.BatchID }) ?? "#")}\">Review Characters</a>";
var progressHtml = string.Empty;
if (reviewComplete && enrichment.IsActive)
{
status = "Analysing";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
description = "PlotDirector is creating character summaries and extracting supported details.";
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Analysing</span>";
progressHtml = ProgressHtml(true, enrichment.ProgressPercent, string.IsNullOrWhiteSpace(enrichment.CurrentMessage) ? "Creating summaries and extracting character details..." : enrichment.CurrentMessage, enrichment.TotalCharacters > 0 ? $"{enrichment.ProcessedCharacters:N0} / {enrichment.TotalCharacters:N0}" : null);
}
else if (reviewComplete && enrichment.IsFailed)
{
status = "Failed";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning";
description = string.IsNullOrWhiteSpace(enrichment.ErrorMessage) ? "Character enrichment failed and can be retried." : enrichment.ErrorMessage;
actionHtml = $$"""
<form method="post" action="{{Url.Action("RetryStoryIntelligenceCharacterEnrichment", "Onboarding")}}">
<input type="hidden" name="batchId" value="{{Model.BatchID}}" />
<button class="btn btn-outline-primary btn-sm" type="submit">Retry</button>
</form>
""";
}
else if (reviewComplete && enrichment.IsCompleted)
{
status = "Complete";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
description = "Character identities, summaries, and supported details are ready.";
var href = Url.Action("Index", "Characters", new { projectId = Model.ProjectID }) ?? "#";
actionHtml = $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(href)}\">View Characters</a>";
}
else if (reviewComplete)
{
status = "Analysing";
statusClass = "story-review-centre-tile__status story-review-centre-tile__status--ready";
description = "Character enrichment is queued.";
actionHtml = "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">Preparing</span>";
progressHtml = ProgressHtml(true, null, "Preparing character analysis...", null);
}
return new Microsoft.AspNetCore.Html.HtmlString($$"""
<article class="story-review-centre-tile story-review-centre-tile--primary" data-character-enrichment-tile data-book-id="{{Model.BookID}}" data-project-id="{{Model.ProjectID}}">
<div>
<h2>Characters</h2>
<p data-character-enrichment-description>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
</div>
<strong class="{{statusClass}}" data-character-enrichment-status>{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
<div data-character-enrichment-progress>{{progressHtml}}</div>
<div data-character-enrichment-action>{{actionHtml}}</div>
</article>
""");
}
private string StatusText(string title, int? count, bool complete, bool blocked)
{
if (blocked)
@ -366,60 +282,4 @@
</article>
""");
}
private static string ProgressHtml(bool visible, int? percent, string message, string? countText)
{
if (!visible)
{
return string.Empty;
}
var determinate = percent.HasValue;
var valueNow = percent.GetValueOrDefault();
return $$"""
<div class="story-review-centre-progress" data-progress-kind="{{(determinate ? "determinate" : "indeterminate")}}">
<div class="story-review-centre-progress__bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"{{(determinate ? $" aria-valuenow=\"{valueNow}\"" : string.Empty)}}>
<span style="width: {{(determinate ? valueNow : 100)}}%"></span>
</div>
<p>{{System.Net.WebUtility.HtmlEncode(message)}}{{(string.IsNullOrWhiteSpace(countText) ? string.Empty : $" {System.Net.WebUtility.HtmlEncode(countText)}")}}</p>
</div>
""";
}
private static int ProgressPercent(StoryIntelligenceProgressViewModel model)
{
if (model.ChapterCount == 0)
{
return 0;
}
var chapterProgress = model.Chapters.Sum(ChapterProgress);
return Math.Clamp((int)Math.Floor(chapterProgress * 100m / model.ChapterCount), 0, 100);
}
private static decimal ChapterProgress(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit || chapter.IsRunComplete || chapter.IsFailed)
{
return 1m;
}
if (chapter.Status == StoryIntelligenceRunStatuses.Pending)
{
return 0m;
}
if ((chapter.TotalDetectedScenes ?? 0) > 0)
{
var sceneProgress = Math.Clamp((chapter.CompletedScenes ?? 0) / Math.Max(1m, chapter.TotalDetectedScenes ?? 1), 0m, 1m);
return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
}
return 0.15m;
}
}
@section Scripts {
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
<script src="~/js/story-review-centre-progress.js" asp-append-version="true"></script>
}

View File

@ -270,36 +270,6 @@
<a class="btn btn-outline-secondary btn-sm" asp-controller="Books" asp-action="Details" asp-route-id="@progressBookId">Book</a>
}
</div>
<div hidden aria-hidden="true" data-story-intelligence-global-runs>
@foreach (var book in storyIntelligenceProgress.Books)
{
<span data-story-intelligence-global-book
data-project-id="@book.ProjectID"
data-book-id="@book.BookID"
data-book-title="@book.BookTitle"
data-status="@book.Status"
data-total-expected-scenes="@book.TotalExpectedScenes"
data-completed-scenes="@book.CompletedScenes"
data-failed-scenes="@book.FailedScenes"
data-active-scenes="@book.ActiveScenes"
data-total-duration-ms="@book.TotalDurationMs"
data-updated-utc="@book.UpdatedUtc.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture)"></span>
@foreach (var run in book.Runs)
{
<span data-story-intelligence-global-run
data-run-id="@run.RunID"
data-project-id="@run.ProjectID"
data-book-id="@run.BookID"
data-book-title="@run.BookTitle"
data-status="@run.Status"
data-total-detected-scenes="@run.TotalDetectedScenes"
data-completed-scenes="@run.CompletedScenes"
data-failed-scenes="@run.FailedScenes"
data-total-duration-ms="@run.TotalDurationMs"
data-updated-utc="@run.UpdatedUtc.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture)"></span>
}
}
</div>
</div>
}
</header>

View File

@ -12,8 +12,6 @@
<nav class="pw-project-nav__links" aria-label="Project sections">
<a class="pw-project-nav__link @(ActiveClass("Overview"))" asp-controller="Projects" asp-action="Details" asp-route-id="@projectId">Overview</a>
<a class="pw-project-nav__link @(ActiveClass("Timeline"))" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@projectId">Timeline</a>
<a class="pw-project-nav__link @(ActiveClass("Calendar"))" asp-controller="StoryCalendar" asp-action="Index" asp-route-projectId="@projectId">Calendar</a>
<a class="pw-project-nav__link @(ActiveClass("At This Point"))" asp-controller="StoryPoint" asp-action="Index" asp-route-projectId="@projectId">At This Point</a>
<a class="pw-project-nav__link @(ActiveClass("Characters"))" asp-controller="Characters" asp-action="Index" asp-route-projectId="@projectId">Characters</a>
<a class="pw-project-nav__link @(ActiveClass("Names"))" asp-controller="Names" asp-action="Index" asp-route-projectId="@projectId">Names</a>
<a class="pw-project-nav__link @(ActiveClass("Relationships"))" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@projectId">Relationships</a>

View File

@ -25,27 +25,15 @@
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story assets</a>
<div class="dropdown pw-actions">
<button class="btn pw-actions__toggle dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Tools</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Asset.StoryAssetID">Edit asset</a>
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Asset.StoryAssetID">Merge with another asset</a>
<div class="dropdown-divider"></div>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this asset?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@Model.Asset.StoryAssetID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="dropdown-item text-danger" type="submit">Archive Asset</button>
</form>
</div>
</div>
<a class="btn btn-outline-primary" asp-action="Edit" asp-route-id="@Model.Asset.StoryAssetID">Edit asset</a>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this asset?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@Model.Asset.StoryAssetID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-danger" type="submit">Archive Asset</button>
</form>
</div>
</div>
@if (TempData["AssetMessage"] is string assetMessage)
{
<div class="alert alert-info">@assetMessage</div>
}
<section class="asset-detail-grid">
<article class="plain-card">
<h2>Current state <help-icon key="storyAssets.currentState" /></h2>

View File

@ -1,53 +0,0 @@
@model EntityMergeViewModel
@{
ViewData["Title"] = $"Merge {Model.SourceName}";
ViewData["ProjectSection"] = "Assets";
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
<a asp-action="Details" asp-route-id="@Model.Form.SourceID">@Model.SourceName</a>
<span>Merge</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading compact">
<div>
<p class="eyebrow">Asset identity</p>
<h1>Merge @Model.SourceName</h1>
<p class="lead-text">Known names, scene references, events, custody, knowledge and dependencies move to the selected asset. The source asset is archived. The selected asset keeps its details.</p>
</div>
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.Form.SourceID">Cancel</a>
</div>
@if (!Model.TargetOptions.Any())
{
<div class="alert alert-info">There are no other active assets in this project to merge with.</div>
}
<form asp-action="Merge" method="post" class="edit-panel">
<input asp-for="Form.SourceID" type="hidden" />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="row g-3">
<div class="col-md-8">
<label asp-for="Form.TargetID" class="form-label">Canonical asset</label>
<select asp-for="Form.TargetID" asp-items="Model.TargetOptions" class="form-select">
<option value="">Choose asset</option>
</select>
</div>
<div class="col-12">
<label class="form-check">
<input asp-for="Form.Confirm" class="form-check-input" />
<span class="form-check-label">I understand this will archive @Model.SourceName and move references to the selected asset.</span>
</label>
</div>
</div>
<button disabled="@(!Model.TargetOptions.Any())" class="btn btn-danger mt-3" type="submit">Merge asset</button>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,21 +0,0 @@
@model StoryCalendarViewModel
@{
ViewData["Title"] = $"Calendar - {Model.Project.ProjectName}";
ViewData["ProjectSection"] = "Calendar";
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Calendar</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div data-story-calendar-root>
<partial name="_StoryCalendarSurface" model="Model" />
</div>
@section Scripts {
<script src="~/js/story-calendar.js" asp-append-version="true"></script>
}

View File

@ -1,56 +0,0 @@
@model StoryCalendarDayDetailsViewModel
@if (Model.SelectedDay is null)
{
<h2>Select a story day</h2>
<p class="muted">Choose a date in the grid to inspect its scenes in narrative order.</p>
}
else
{
<h2>@Model.SelectedDay.DayLabel</h2>
@if (!Model.SelectedDay.Scenes.Any())
{
<p class="muted">No scenes are placed on this story date.</p>
}
else
{
<div class="story-calendar-detail-list">
@foreach (var scene in Model.SelectedDay.Scenes)
{
<article class="story-calendar-detail-scene">
<div class="story-calendar-detail-meta">
@if (Model.ShowBookLabels)
{
<span>@scene.BookTitle</span>
}
<span>Chapter @scene.ChapterNumber:g</span>
<span>Scene @scene.SceneNumber:g</span>
</div>
<h3>@scene.Title</h3>
<dl>
@if (!string.IsNullOrWhiteSpace(scene.Pov))
{
<dt>POV</dt>
<dd>@scene.Pov</dd>
}
<dt>Time</dt>
<dd>@scene.TimeLabel</dd>
</dl>
@if (!string.IsNullOrWhiteSpace(scene.Summary))
{
<p>@scene.Summary</p>
}
<div class="button-row">
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
<a class="btn btn-outline-secondary btn-sm"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.ProjectID"
asp-route-bookId="@scene.BookID"
asp-route-selectedSceneId="@scene.SceneID">Open in Timeline</a>
</div>
</article>
}
</div>
}
}

View File

@ -1,202 +0,0 @@
@model StoryCalendarViewModel
@using System.Globalization
@{
var selectedMonthValue = Model.SelectedMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture);
var showBookLabels = !Model.SelectedBookID.HasValue;
var weekdays = new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };
}
<div class="page-heading story-calendar-heading">
<div>
<p class="eyebrow">Story time</p>
<h1>Calendar</h1>
<p class="lead-text">@Model.StoryRangeLabel</p>
</div>
<div class="story-calendar-stats" aria-label="Calendar summary">
<span><strong>@Model.SceneCount</strong> dated scenes</span>
<span><strong>@Model.ActiveStoryDayCount</strong> story days</span>
<span><strong>@Model.UnplacedScenes.Count</strong> unplaced</span>
</div>
</div>
<section class="card mb-3 story-calendar-filter">
<div class="card-body">
<form asp-action="ApplyFilters" method="post" class="row g-2 align-items-end" data-story-calendar-filter data-story-calendar-index-url="@Url.Action("Index", "StoryCalendar")">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div class="col-md-5 col-lg-4">
<label class="form-label" for="bookId">Book</label>
<select class="form-select" id="bookId" name="bookId" asp-items="Model.BookOptions"></select>
</div>
<div class="col-md-4 col-lg-3">
<label class="form-label" for="month">Month</label>
<select class="form-select" id="month" name="month" disabled="@(!Model.MonthOptions.Any())">
@if (!Model.MonthOptions.Any())
{
<option value="">No dated scenes</option>
}
else
{
@foreach (var option in Model.MonthOptions)
{
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
}
}
</select>
</div>
<div class="col-md-3 col-lg-2">
<button class="btn btn-primary w-100" type="submit">Apply</button>
</div>
</form>
</div>
</section>
@if (!Model.HasDatedScenes)
{
<section class="card story-calendar-empty">
<div class="card-body">
<h2>No dated scenes yet</h2>
<p class="muted mb-0">Scenes will appear here once canonical scene chronology has a resolved start date.</p>
</div>
</section>
}
else
{
<section class="story-calendar-shell">
<div class="story-calendar-toolbar">
<div class="button-row">
<a class="btn btn-outline-secondary btn-sm"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.PreviousMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
data-story-calendar-nav>Previous month</a>
<a class="btn btn-outline-secondary btn-sm"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.NextMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
data-story-calendar-nav>Next month</a>
@if (Model.FirstStoryMonth.HasValue)
{
<a class="btn btn-outline-secondary btn-sm"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.FirstStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
data-story-calendar-nav>First dated scene</a>
}
</div>
<div class="story-calendar-story-months">
@if (Model.PreviousStoryMonth.HasValue)
{
<a asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.PreviousStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
data-story-calendar-nav>Previous story month</a>
}
@if (Model.NextStoryMonth.HasValue)
{
<a asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.NextStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)"
data-story-calendar-nav>Next story month</a>
}
</div>
</div>
<div class="story-calendar-layout">
<div class="story-calendar-grid-wrap">
<div class="story-calendar-month" aria-label="@Model.SelectedMonthLabel">
<div class="story-calendar-month-title">@Model.SelectedMonthLabel</div>
<div class="story-calendar-weekdays">
@foreach (var weekday in weekdays)
{
<div>@weekday</div>
}
</div>
<div class="story-calendar-grid">
@foreach (var week in Model.Weeks)
{
@foreach (var day in week.Days)
{
var visibleScenes = day.Scenes.Take(3).ToList();
var hiddenCount = Math.Max(0, day.Scenes.Count - visibleScenes.Count);
<a class="story-calendar-day @(day.IsInSelectedMonth ? "" : "is-overflow") @(day.IsSelected ? "is-selected" : "")"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@selectedMonthValue"
asp-route-date="@day.QueryValue"
data-story-calendar-day
data-story-calendar-date="@day.QueryValue"
aria-current="@(day.IsSelected ? "date" : null)"
aria-label="@day.DayLabel">
<span class="story-calendar-day-number">@day.DayNumber</span>
<span class="story-calendar-day-scenes">
@foreach (var scene in visibleScenes)
{
<span class="story-calendar-scene-chip">
@if (showBookLabels)
{
<span class="story-calendar-book">@scene.BookLabel</span>
}
<span>@scene.SceneLabel</span>
<strong>@scene.Title</strong>
</span>
}
@if (hiddenCount > 0)
{
<span class="story-calendar-more">+ @hiddenCount more</span>
}
</span>
</a>
}
}
</div>
</div>
</div>
<aside class="story-calendar-detail" aria-label="Selected day" aria-live="polite" data-story-calendar-detail>
<partial name="_StoryCalendarDayDetails" model="Model.DayDetails" />
</aside>
</div>
</section>
}
<section class="card story-calendar-unplaced">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Unplaced scenes</strong>
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#story-calendar-unplaced" aria-expanded="false" aria-controls="story-calendar-unplaced">
@Model.UnplacedScenes.Count scenes
</button>
</div>
<div class="collapse" id="story-calendar-unplaced">
<div class="card-body">
@if (!Model.UnplacedScenes.Any())
{
<p class="muted mb-0">All scenes in this scope have resolved story dates.</p>
}
else
{
<div class="story-calendar-unplaced-list">
@foreach (var scene in Model.UnplacedScenes)
{
<article>
<div>
<strong>@scene.Title</strong>
<span>@scene.BookTitle · Chapter @scene.ChapterNumber:g · Scene @scene.SceneNumber:g</span>
@if (!string.IsNullOrWhiteSpace(scene.RelativeTimeText))
{
<small>@scene.RelativeTimeText</small>
}
</div>
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
</article>
}
</div>
}
</div>
</div>
</section>

View File

@ -1,30 +0,0 @@
@model StoryPointViewModel
@{
ViewData["Title"] = $"At This Point - {Model.Project.ProjectName}";
ViewData["ProjectSection"] = "At This Point";
}
<link rel="stylesheet" href="~/css/story-point.css" asp-append-version="true" />
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>At This Point</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading"><div><p class="eyebrow">Writing context</p><h1>AT THIS POINT IN THE STORY</h1>
<p class="lead-text">Known state through the selected scene, including events in this scene.</p></div></div>
<form asp-action="Index" method="get" class="story-point-selectors" data-story-point-form>
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div><label for="point-book" class="form-label">Book</label><select id="point-book" class="form-select" data-point-book>
@foreach (var book in Model.Books) { <option value="@book.BookID" selected="@(book.BookID == Model.Snapshot?.Book.BookID)">Book @book.BookNumber: @book.BookDisplayTitle</option> }
</select></div>
<div><label for="point-chapter" class="form-label">Chapter</label><select id="point-chapter" class="form-select" data-point-chapter>
@foreach (var chapter in Model.Chapters) { <option value="@chapter.ChapterID" data-book="@chapter.BookID" selected="@(chapter.ChapterID == Model.Snapshot?.Chapter.ChapterID)">Ch @chapter.ChapterNumber.ToString("0.##"): @chapter.ChapterTitle</option> }
</select></div>
<div><label for="point-scene" class="form-label">Scene</label><select id="point-scene" class="form-select" name="sceneId" data-point-scene>
@foreach (var scene in Model.Scenes) { <option value="@scene.SceneID" data-chapter="@scene.ChapterID" selected="@(scene.SceneID == Model.Snapshot?.Scene.SceneID)">Scene @scene.SceneNumber.ToString("0.##"): @scene.SceneTitle</option> }
</select></div>
<button class="btn btn-primary" type="submit">Show state</button>
</form>
<p data-point-feedback role="status" class="text-muted small"></p>
<div data-point-snapshot><partial name="_Snapshot" model="Model.Snapshot" /></div>
@section Scripts { <script src="~/js/story-point.js" asp-append-version="true"></script> }

View File

@ -1,85 +0,0 @@
@model StoryPointSnapshot
@using System.Globalization
@if (Model is null)
{
<p class="text-muted">No scenes are available in this project.</p>
}
else
{
var scene = Model.Scene;
var exactTime = scene.TimeModeName is "Exact DateTime" or "Exact Date and Time";
<article class="story-point" data-scene-id="@scene.SceneID">
<header class="story-point-header">
<p class="eyebrow">@Model.Book.BookDisplayTitle · Chapter @Model.Chapter.ChapterNumber.ToString("0.##") · Scene @scene.SceneNumber.ToString("0.##")</p>
<h2>@scene.SceneTitle</h2>
<p class="story-point-date">
@if (scene.StartDateTime.HasValue)
{ <span>@scene.StartDateTime.Value.ToString("dddd, d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"))</span>
@if (exactTime) { <span> · @scene.StartDateTime.Value.ToString("h:mm tt", CultureInfo.GetCultureInfo("en-GB"))</span> }
}
else { <span>Story date unknown</span> }
@if (!string.IsNullOrWhiteSpace(scene.RelativeTimeText)) { <span> · @scene.RelativeTimeText</span> }
<small class="text-muted"> · @scene.TimeModeName</small>
</p>
<p>POV: <strong>@(Model.PovName ?? "Not recorded")</strong> · Location: <strong>@(Model.Location?.LocationPath ?? scene.PrimaryLocationName ?? "Not recorded")</strong></p>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-primary" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open Scene</a>
<a class="btn btn-sm btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.ProjectID" asp-route-bookId="@Model.Book.BookID" asp-route-selectedSceneId="@scene.SceneID">Timeline</a>
@if (scene.StartDateTime.HasValue) { <a class="btn btn-sm btn-outline-secondary" asp-controller="StoryCalendar" asp-action="Index" asp-route-projectId="@Model.ProjectID" asp-route-bookId="@Model.Book.BookID" asp-route-date="@scene.StartDateTime.Value.ToString("yyyy-MM-dd")" asp-route-month="@scene.StartDateTime.Value.ToString("yyyy-MM")">Calendar</a> }
</div>
</header>
<div class="story-point-grid">
<section class="story-point-section story-point-wide"><h3>Characters present <small>@Model.Characters.Count</small></h3>
@if (Model.Characters.Count == 0) { <p class="text-muted">No characters attached to this scene.</p> }
else { <div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Character</th><th>Age at this point</th><th>Known location</th><th>Previous appearance</th></tr></thead><tbody>
@foreach (var person in Model.Characters) { <tr><td><a asp-controller="Characters" asp-action="Edit" asp-route-id="@person.Appearance.CharacterID">@person.Appearance.CharacterName</a>
<small class="d-block text-muted">@string.Join(" · ", new[] { person.Appearance.RoleInSceneTypeName, person.Appearance.PresenceTypeName }.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))</small></td>
<td>@(person.Age.DisplayText ?? "Unknown") @if (!person.Age.HasExactAge && person.Age.DisplayText is not null) { <small class="d-block text-muted">Approximate</small> }</td>
<td>@(person.State?.CurrentLocation ?? "Unknown")<small class="d-block text-muted">@(person.State?.State == "Inferred" ? "Last known" : "")</small></td>
<td>@if (person.PreviousAppearance is { } previous) { <a asp-controller="Scenes" asp-action="Edit" asp-route-id="@previous.SceneID">@previous.Label</a> } else { <span class="text-muted">First recorded appearance</span> }</td></tr> }
</tbody></table></div> }
</section>
<section class="story-point-section"><h3>Character knowledge</h3>
@if (Model.Characters.All(x => x.Knowledge.Count == 0)) { <p class="text-muted">No structured knowledge recorded by this point.</p> }
@foreach (var person in Model.Characters.Where(x => x.Knowledge.Count > 0)) {
<h4>@person.Appearance.CharacterName</h4><ul class="story-point-facts">
@foreach (var fact in person.Knowledge) { <li><strong>@fact.KnowledgeStateName:</strong> @(string.IsNullOrWhiteSpace(fact.Description) ? fact.AssetName ?? fact.ThreadTitle ?? "Recorded fact" : fact.Description)
<a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@fact.SceneID">@(Model.Sources.GetValueOrDefault(fact.SceneID)?.Label ?? "Source scene")</a></li> }
</ul>
}
</section>
<section class="story-point-section"><h3>Relationships at this point</h3>
@if (Model.Relationships.Count == 0) { <p class="text-muted">No historically established relationships recorded for these characters.</p> }
<ul class="story-point-facts">@foreach (var relationship in Model.Relationships) {
var source = relationship.History.LastOrDefault(x => x.SceneID.HasValue);
<li><strong>@relationship.CharacterAName ↔ @relationship.CharacterBName</strong><div>@relationship.CurrentStateName</div>
@if (source?.SceneID is int sourceId) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@sourceId">@(Model.Sources.GetValueOrDefault(sourceId)?.Label ?? "Source scene")</a> }
else { <small class="text-muted">Established at story start</small> }</li>
}</ul></section>
<section class="story-point-section story-point-wide"><h3>Assets / important objects</h3>
@if (Model.Assets.Count == 0) { <p class="text-muted">No relevant assets recorded by this point.</p> }
else { <div class="table-responsive"><table class="table table-sm"><thead><tr><th>Asset</th><th>Holder</th><th>Location</th><th>Status</th><th>Last recorded event</th></tr></thead><tbody>
@foreach (var asset in Model.Assets) { <tr><td><a asp-controller="StoryAssets" asp-action="Edit" asp-route-id="@asset.StoryAssetID">@asset.AssetName</a></td><td>@asset.Holder</td><td>@asset.Location</td><td>@asset.Status</td><td>@asset.LatestEvent
@if (asset.LastConfirmedSceneID is int sourceId) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@sourceId">@(Model.Sources.GetValueOrDefault(sourceId)?.Label ?? "Source scene")</a> }</td></tr> }
</tbody></table></div> }
</section>
<section class="story-point-section"><h3>Location state</h3>
@if (Model.Location is { } location) { <h4><a asp-controller="Locations" asp-action="Edit" asp-route-id="@location.LocationID">@location.LocationPath</a></h4>
<p><strong>People last known here:</strong> @(location.CharactersPresent.Count == 0 ? "None recorded" : string.Join(", ", location.CharactersPresent))</p>
<p><strong>Assets last known here:</strong> @(location.AssetsPresent.Count == 0 ? "None recorded" : string.Join(", ", location.AssetsPresent))</p>
@if (Model.PreviousLocationUse is { } previous) { <p>Previous scene here: <a asp-controller="Scenes" asp-action="Edit" asp-route-id="@previous.SceneID">@previous.Label</a></p> }
} else { <p class="text-muted">No primary location recorded.</p> }
</section>
<section class="story-point-section"><h3>Active threads / questions / clues</h3>
@if (Model.Threads.Count == 0) { <p class="text-muted">No active threads established by this point.</p> }
<ul class="story-point-facts">@foreach (var thread in Model.Threads) { <li><a asp-controller="PlotThreads" asp-action="Edit" asp-route-id="@thread.PlotThreadID">@thread.Title</a> <small class="text-muted">@thread.Type · @thread.State</small>
@if (thread.Source is { } source) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@source.SceneID">@source.Label</a> }</li> }</ul>
</section>
<section class="story-point-section story-point-wide"><h3>Things worth checking at this point</h3>
@if (Model.Warnings.Count == 0) { <p class="text-muted">No relevant continuity warnings.</p> }
<ul class="story-point-facts">@foreach (var warning in Model.Warnings) { <li><strong>@warning.Severity:</strong> @warning.Message
@if (warning.SceneID is int sourceId) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@sourceId">@(Model.Sources.GetValueOrDefault(sourceId)?.Label ?? "Source scene")</a> }</li> }</ul>
</section>
</div>
</article>
}

View File

@ -1710,45 +1710,6 @@ summary.story-review-chapter-heading {
background: color-mix(in srgb, var(--plotline-panel, var(--bs-body-bg)) 94%, var(--plotline-line, rgba(31, 42, 68, .12)));
}
.story-review-centre-progress {
width: 100%;
}
.story-review-centre-progress__bar {
height: .5rem;
overflow: hidden;
background: rgba(31, 42, 68, .12);
border-radius: 999px;
}
.story-review-centre-progress__bar span {
display: block;
height: 100%;
min-width: 12%;
background: var(--plotline-accent, #3b82f6);
transition: width .25s ease;
}
.story-review-centre-progress[data-progress-kind="indeterminate"] .story-review-centre-progress__bar span {
width: 45% !important;
animation: story-review-progress-sweep 1.2s ease-in-out infinite;
}
.story-review-centre-progress p {
margin: .35rem 0 0;
font-size: .9rem;
color: var(--bs-secondary-color);
}
@keyframes story-review-progress-sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(240%);
}
}
.story-review-centre-actions {
justify-content: flex-start;
}

Some files were not shown because too many files have changed in this diff Show More