Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion ARCHAI_v10_8.html
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@
.od-comment-flag.safe{background:rgba(74,170,119,0.15);color:#4a7;}
.od-comment-flag.suspicious{background:rgba(200,136,68,0.15);color:#c84;}
.od-comment-flag.harmful{background:rgba(200,68,68,0.15);color:#c44;}
.od-comment-flag.verified{background:rgba(74,170,119,0.15);color:#4a7;border:1px solid rgba(74,170,119,0.35);}
.od-comment-actions-inline{display:flex;gap:6px;margin-left:auto;}
.od-comment-action-btn{background:none;border:1px solid var(--border2);color:var(--text3);font-family:var(--mono);font-size:0.42rem;padding:2px 8px;cursor:pointer;letter-spacing:1px;text-transform:uppercase;}
.od-comment-action-btn:hover{border-color:var(--accent2);color:var(--accent2);}
Expand Down Expand Up @@ -4470,15 +4471,19 @@
const hidden = c.status === 'hidden';
const replies = (c.replies || []).map(r => renderODComment(r, depth + 1)).join('');

const verified = !!c.verified;

return `<div class="od-comment-item ${flagClass}" style="${hidden ? 'opacity:0.6;' : ''}">
<div class="od-comment-text">${hidden ? '⚠ ' : ''}${escHTML(c.text)}</div>
<div class="od-comment-meta">
<span>${c.author_type || 'visitor'}</span>
<span>${c.created_at ? new Date(c.created_at).toLocaleDateString() : ''}</span>
${c.ai_flag ? `<span class="od-comment-flag ${c.ai_flag}">${c.ai_flag}</span>` : ''}
${verified ? '<span class="od-comment-flag verified" title="Verified oral history">✓ verified</span>' : ''}
${c.ai_reason ? `<span style="font-style:italic;">${escHTML(c.ai_reason)}</span>` : ''}
<span class="od-comment-actions-inline">
${hidden ? `<button class="od-comment-action-btn" onclick="odModerateComment('${c.id}','visible')">Approve</button>` : ''}
${!hidden ? `<button class="od-comment-action-btn" onclick="odVerifyComment('${c.id}', ${verified ? 'false' : 'true'})">${verified ? 'Unverify' : 'Verify'}</button>` : ''}
<button class="od-comment-action-btn remove" onclick="odModerateComment('${c.id}','removed')">Remove</button>
<button class="od-comment-action-btn" onclick="odReplyTo('${c.id}')">Reply</button>
</span>
Expand All @@ -4497,6 +4502,16 @@
.then(() => { if (_odCurrentObjectId) odLoadComments(_odCurrentObjectId); });
}

function odVerifyComment(commentId, verified) {
fetch(`${COMMENTS_API}/${commentId}/verify`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verified, moderator: 'curator' }),
})
.then(r => r.json())
.then(() => { if (_odCurrentObjectId) odLoadComments(_odCurrentObjectId); });
}

function odReplyTo(parentId) {
const input = document.getElementById('od-comment-input');
input.focus();
Expand Down Expand Up @@ -5208,7 +5223,7 @@
list.innerHTML = comments.slice(0, 10).map(c => `
<div class="vpc-item vpc-flag-${c.status === 'approved' ? 'safe' : c.status}">
<div>${escHTML(c.text)}</div>
<div class="vpc-item-meta">${c.author_type} · ${new Date(c.created_at).toLocaleDateString()}</div>
<div class="vpc-item-meta">${c.author_type} · ${new Date(c.created_at).toLocaleDateString()}${c.verified ? ' <span class="od-comment-flag verified" title="Verified oral history">✓ verified</span>' : ''}</div>
</div>`).join('');
})
.catch(() => { list.innerHTML = ''; });
Expand Down
5 changes: 4 additions & 1 deletion REPO_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ GitHub `main` branch notes and operations guide.
### 2. Backend API and proxy
- `backend-archai/src/server.js` - Express entry point
- `backend-archai/src/routes/proxy.js` - safe Qdrant/Ollama/curator proxy
- `backend-archai/src/routes/comments.js` - visitor comment API
- `backend-archai/src/routes/comments.js` - visitor comment API (+ oral-history verification)
- `backend-archai/src/services/moderation.js` - AI moderation for comments
- `backend-archai/src/services/verificationService.js` + `routes/verification.js` - verification tiers (institutional record / verified oral history / community response)
- `backend-archai/src/services/accessionService.js` + `routes/accession.js` - accession CMS path (verified + approved → CollectiveAccess)
- `docs/VERIFICATION_AND_ACCESSION.md` - how verification and the accession path work
- `backend-archai/src/services/curator-vectors.js` - curator vector collection build/search
- `backend-archai/src/data/db.js` - SQLite persistence layer

Expand Down
1 change: 1 addition & 0 deletions backend-archai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"seed:mock": "node src/pipeline/seedMockData.js",
"harvest:legal": "node scripts/legal-harvest-bot.js --report",
"test:smoke": "node scripts/smoke-test-runtime.js",
"test:verification": "node scripts/test-verification-accession.js",
"lint:quick": "node -e \"console.log('No linter configured yet')\""
},
"keywords": [
Expand Down
80 changes: 80 additions & 0 deletions backend-archai/scripts/test-verification-accession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env node
// Standalone checks for the verification layer and accession CMS path.
// Runs without the database/native deps — it exercises the repo-backed services
// directly. Point AUX_WORKBENCH_FILE at a throwaway path so the real workbench
// is never touched.
//
// AUX_WORKBENCH_FILE=$(mktemp) node scripts/test-verification-accession.js

import { repo } from '../src/services/objectRepository.js';
import { deriveVerification, setVerification, verificationSummary } from '../src/services/verificationService.js';
import { accessionReadiness, listAccessionQueue, commitAccession } from '../src/services/accessionService.js';

let failed = 0;
function check(label, cond) {
if (cond) {
console.log(`OK ${label}`);
} else {
failed += 1;
console.log(`FAIL ${label}`);
}
}

// ── Verification summary reflects the seeded tiers ───────────────────
const summary = verificationSummary();
check('summary counts every object', summary.total === repo.state.objects.length);
check('summary reports both verified and unverified records', summary.verified > 0 && summary.unverified > 0);
check('Echo Circuit (in review) reads as unverified',
deriveVerification(repo.getObject('FAMTEC_1997_0234')).verified === false);
check('Synthetic Garden reads as a verified institutional record',
deriveVerification(repo.getObject('FAMTEC_2022_0087')).verified === true);

// ── Accession gate: unverified records cannot enter the CMS ──────────
const echoReadiness = accessionReadiness(repo.getObject('FAMTEC_1997_0234'));
check('unverified record is blocked from accession',
!echoReadiness.ready && echoReadiness.blockers.includes('not_verified'));
const echoCommit = commitAccession({ objectId: 'FAMTEC_1997_0234', actorEmail: 'curator@famtec.au' });
check('commitAccession refuses an unverified record (409 + blocker)',
echoCommit.error && echoCommit.status === 409 && echoCommit.blockers.includes('not_verified'));

// ── Accession queue holds verified + approved, not-yet-accessioned ───
const queue = listAccessionQueue();
check('born-digital ARCHAI demonstrator is queued for accession',
queue.some((r) => r.objectId === 'FAMTEC_2026_ARCHAI_WEB'));
check('already-accessioned record is not queued',
!queue.some((r) => r.objectId === 'FAMTEC_2022_0087'));

// ── Committing a queued record assigns an accession number + CMS id ──
const before = repo.getObject('FAMTEC_2026_ARCHAI_WEB');
check('queued record starts with no CMS id', !before.source?.collectiveAccessId);
const commit = commitAccession({ objectId: 'FAMTEC_2026_ARCHAI_WEB', actorEmail: 'curator@famtec.au' });
check('commitAccession succeeds for a ready record', Boolean(commit.accession) && !commit.error);
check('accession number matches FAMTEC.YYYY.NNNN',
/^FAMTEC\.\d{4}\.\d{4}$/.test(commit.accession?.accessionNumber || ''));
check('CMS record id is written back onto source.collectiveAccessId',
repo.getObject('FAMTEC_2026_ARCHAI_WEB').source.collectiveAccessId === commit.accession.cmsRecordId);
check('re-committing an accessioned record is blocked',
commitAccession({ objectId: 'FAMTEC_2026_ARCHAI_WEB' }).blockers?.includes('already_accessioned'));

// ── Full path: verify → approve → accession a previously blocked record
setVerification({ objectId: 'FAMTEC_1997_0234', tier: 'institutional_record', verified: true, actorEmail: 'curator@famtec.au' });
check('setVerification flips Echo Circuit to verified',
deriveVerification(repo.getObject('FAMTEC_1997_0234')).verified === true);
check('still blocked while workflow is not approved/published',
accessionReadiness(repo.getObject('FAMTEC_1997_0234')).blockers.includes('workflow_review'));
repo.updateObject('FAMTEC_1997_0234', { workflow: { state: 'approved', updatedBy: 'curator@famtec.au', updatedAt: new Date().toISOString() } });
// Echo Circuit already carries a legacy CMS id, so it is treated as accessioned;
// clearing it lets us prove the verify → approve → commit path end to end.
repo.updateObject('FAMTEC_1997_0234', {
source: { ...repo.getObject('FAMTEC_1997_0234').source, collectiveAccessId: null },
accession: { status: 'not_accessioned', accessionNumber: null, cmsTarget: 'collectiveaccess', cmsRecordId: null, accessionedBy: null, accessionedAt: null, notes: '' },
});
const echoCommit2 = commitAccession({ objectId: 'FAMTEC_1997_0234', actorEmail: 'curator@famtec.au' });
check('verified + approved record now accessions successfully',
Boolean(echoCommit2.accession) && !echoCommit2.error);

if (failed) {
console.error(`\n${failed} verification/accession check(s) failed.`);
process.exit(1);
}
console.log('\nAll verification/accession checks passed.');
23 changes: 18 additions & 5 deletions backend-archai/src/data/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,29 @@ if (!tableExists) {
status TEXT NOT NULL DEFAULT 'visible',
ai_flag TEXT DEFAULT NULL,
ai_reason TEXT DEFAULT NULL,
verified INTEGER NOT NULL DEFAULT 0,
verified_by TEXT DEFAULT NULL,
verified_at TEXT DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
moderated_at TEXT DEFAULT NULL,
moderated_by TEXT DEFAULT NULL
);
`);
} else {
// Migrate: add parent_id if missing
const cols = db.prepare("PRAGMA table_info(comments)").all().map(c => c.name);
if (!cols.includes('parent_id')) {
db.exec('ALTER TABLE comments ADD COLUMN parent_id TEXT DEFAULT NULL');
}

// Bring pre-existing databases up to the current schema. A community memory can
// be confirmed by a curator into a verified oral history, so comments carry the
// same verification line that object records do.
{
const cols = db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name);
const migrations = [
['parent_id', "ALTER TABLE comments ADD COLUMN parent_id TEXT DEFAULT NULL"],
['verified', "ALTER TABLE comments ADD COLUMN verified INTEGER NOT NULL DEFAULT 0"],
['verified_by', "ALTER TABLE comments ADD COLUMN verified_by TEXT DEFAULT NULL"],
['verified_at', "ALTER TABLE comments ADD COLUMN verified_at TEXT DEFAULT NULL"],
];
for (const [name, ddl] of migrations) {
if (!cols.includes(name)) db.exec(ddl);
}
}

Expand Down
12 changes: 12 additions & 0 deletions backend-archai/src/data/mockObjects.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const mockObjects = [
prohibitedStatements: ['Invented artist biography', 'Unsupported claims about emotions of visitors'],
restrictions: [],
workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt },
verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: '' },
accession: { status: 'accessioned', accessionNumber: 'FAMTEC.2022.0087', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1008', accessionedBy: 'curator@famtec.au', accessionedAt: updatedAt, notes: '' },
updatedAt,
},
{
Expand All @@ -47,6 +49,8 @@ export const mockObjects = [
prohibitedStatements: ['Claims of consciousness', 'Unsupported personal memories'],
restrictions: [],
workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt },
verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: '' },
accession: { status: 'accessioned', accessionNumber: 'FAMTEC.2018.0001', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1001', accessionedBy: 'curator@famtec.au', accessionedAt: updatedAt, notes: '' },
updatedAt,
},
{
Expand All @@ -70,6 +74,8 @@ export const mockObjects = [
prohibitedStatements: ['Claims of exact hardware equivalence without testing'],
restrictions: [],
workflow: { state: 'review', updatedBy: 'collections@famtec.au', updatedAt },
verification: { tier: 'institutional_record', verified: false, verifiedBy: null, verifiedAt: null, method: null, notes: 'Provenance still under curatorial review.' },
accession: { status: 'accessioned', accessionNumber: 'FAMTEC.1997.0234', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1051', accessionedBy: 'collections@famtec.au', accessionedAt: updatedAt, notes: 'Legacy accession; verification pending.' },
updatedAt,
},
{
Expand All @@ -93,6 +99,8 @@ export const mockObjects = [
prohibitedStatements: ['Attribution to historical broadcasts not documented'],
restrictions: [],
workflow: { state: 'approved', updatedBy: 'curator@famtec.au', updatedAt },
verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: '' },
accession: { status: 'accessioned', accessionNumber: 'FAMTEC.2019.0051', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1019', accessionedBy: 'curator@famtec.au', accessionedAt: updatedAt, notes: '' },
updatedAt,
},
{
Expand All @@ -116,6 +124,8 @@ export const mockObjects = [
prohibitedStatements: ['Claims that the archived capture is the current production system'],
restrictions: [],
workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt },
verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: 'Institution-owned capture approved.' },
accession: { status: 'not_accessioned', accessionNumber: null, cmsTarget: 'collectiveaccess', cmsRecordId: null, accessionedBy: null, accessionedAt: null, notes: 'Born-digital capture verified and awaiting accession into CollectiveAccess.' },
updatedAt,
},
{
Expand All @@ -139,6 +149,8 @@ export const mockObjects = [
prohibitedStatements: ['Claims that the archived capture is a museum-owned collection record'],
restrictions: [],
workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt },
verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: 'Institution-owned capture approved.' },
accession: { status: 'not_accessioned', accessionNumber: null, cmsTarget: 'collectiveaccess', cmsRecordId: null, accessionedBy: null, accessionedAt: null, notes: 'Born-digital capture verified and awaiting accession into CollectiveAccess.' },
updatedAt,
},
];
Expand Down
4 changes: 2 additions & 2 deletions backend-archai/src/policies/roleCapabilities.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const roleCapabilities = {
admin: ['*'],
curator: ['objects.read', 'objects.write', 'workflow.transition', 'nfc.publish', 'media.publish', 'vocab.read', 'search', 'export'],
collections: ['objects.read', 'objects.write-limited', 'upload.queue', 'workflow.submit-review', 'vocab.read', 'search'],
curator: ['objects.read', 'objects.write', 'workflow.transition', 'verification.set', 'accession.commit', 'nfc.publish', 'media.publish', 'vocab.read', 'search', 'export'],
collections: ['objects.read', 'objects.write-limited', 'upload.queue', 'workflow.submit-review', 'accession.read', 'vocab.read', 'search'],
technician: ['nodel.read', 'runtime.read', 'media.validate', 'objects.read-tech'],
volunteer: ['objects.read', 'notes.create', 'visitor.preview'],
visitor: ['nfc.public', 'chat.object', 'comments.create'],
Expand Down
34 changes: 34 additions & 0 deletions backend-archai/src/routes/accession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Router } from 'express';
import { z } from 'zod';
import { requireRole } from '../middleware/requireRole.js';
import { repo } from '../services/objectRepository.js';
import { listAccessionQueue, accessionReadiness, commitAccession } from '../services/accessionService.js';

export const accessionRouter = Router();

const commitSchema = z.object({
objectId: z.string().min(1),
notes: z.string().optional().default(''),
});

// Records that are verified + approved and waiting to enter the archival CMS.
accessionRouter.get('/queue', requireRole('collections'), (_req, res) => {
res.json({ ok: true, queue: listAccessionQueue() });
});

// Accession readiness (and blockers) for a single record.
accessionRouter.get('/:id', requireRole('collections'), (req, res) => {
const record = repo.getObject(req.params.id);
if (!record) return res.status(404).json({ ok: false, error: 'Object not found' });
res.json({ ok: true, objectId: record.id, ...accessionReadiness(record) });
});

// Commit a record into the archival CMS. Gated: verified + approved only.
accessionRouter.post('/commit', requireRole('curator'), (req, res) => {
const input = commitSchema.parse(req.body || {});
const result = commitAccession({ ...input, actorEmail: req.archai.user.email });
if (result.error) {
return res.status(result.status || 400).json({ ok: false, error: result.error, blockers: result.blockers });
}
res.json({ ok: true, ...result });
});
18 changes: 18 additions & 0 deletions backend-archai/src/routes/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ const updateStatus = db.prepare(`
UPDATE comments SET status = ?, moderated_at = datetime('now'), moderated_by = ? WHERE id = ?
`);

const verifyComment = db.prepare(`
UPDATE comments
SET verified = ?, verified_by = ?, verified_at = CASE WHEN ? = 1 THEN datetime('now') ELSE NULL END
WHERE id = ?
`);

const getStats = db.prepare(`
SELECT status, ai_flag, COUNT(*) as count FROM comments GROUP BY status, ai_flag
`);
Expand Down Expand Up @@ -142,6 +148,18 @@ commentsRouter.patch('/:id', (req, res) => {
res.json({ ok: true, updated: req.params.id, status });
});

// ── Curator: confirm a community memory as a verified oral history ─
// This is the comment-side of the verification line: a visible community
// response becomes a verified oral history, and carries a verified marker on
// the public "love" pages so the two tiers stay visibly distinct.
commentsRouter.patch('/:id/verify', (req, res) => {
const verified = req.body?.verified === false ? 0 : 1;
const moderator = req.body?.moderator || 'curator';
const result = verifyComment.run(verified, verified ? moderator : null, verified, req.params.id);
if (result.changes === 0) return res.status(404).json({ ok: false, error: 'Comment not found' });
res.json({ ok: true, updated: req.params.id, verified: Boolean(verified) });
});

// ── Curator: which objects have comments ─────────────────────────
commentsRouter.get('/objects', (_req, res) => {
const rows = getObjectsWithComments.all();
Expand Down
4 changes: 4 additions & 0 deletions backend-archai/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { integrationsRouter } from './integrations.js';
import { commentsRouter } from './comments.js';
import { mediaRouter } from './media.js';
import { workflowsRouter } from './workflows.js';
import { verificationRouter } from './verification.js';
import { accessionRouter } from './accession.js';
import { webhooksRouter } from './webhooks/index.js';
import { proxyRouter } from './proxy.js';

Expand All @@ -34,5 +36,7 @@ apiRouter.use('/integrations', integrationsRouter);
apiRouter.use('/comments', commentsRouter);
apiRouter.use('/media', mediaRouter);
apiRouter.use('/workflows', workflowsRouter);
apiRouter.use('/verification', verificationRouter);
apiRouter.use('/accession', accessionRouter);
apiRouter.use('/webhooks', webhooksRouter);
apiRouter.use('/proxy', proxyRouter);
Loading