@anthusai/papyrus 5.56.1 → 5.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +25 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +25 -3
- package/amplify/data/schema.ts +25 -3
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +188 -0
- package/components/conversations-memory-source.ts +46 -0
- package/components/conversations-network-source.ts +40 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +24 -0
- package/components/conversations-view.tsx +319 -0
- package/components/decision-control.tsx +307 -0
- package/components/needs-me-bodies.tsx +4 -3
- package/components/needs-me-feed.tsx +8 -14
- package/components/needs-me-reference-detail.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +322 -37
- package/components/newsroom-organizer.css +24 -0
- package/components/newsroom-references-view.tsx +9 -0
- package/components/notification-cards.tsx +5 -4
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +69 -37
- package/components/reference-review-card.tsx +8 -134
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +43 -784
- package/components/use-conversation.ts +84 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +7 -0
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +154 -0
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +15 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
package/lib/reference-policy.ts
CHANGED
|
@@ -22,6 +22,84 @@ export function isScopeTrainingNegativeReason(code: string): boolean {
|
|
|
22
22
|
return SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(code);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The phrases an editor writes when they mean one reason code and little else.
|
|
27
|
+
*
|
|
28
|
+
* The order matters, and the two scope-training codes come LAST: a sentence
|
|
29
|
+
* that also names a mechanical reason ("already have it, and off our beat") is
|
|
30
|
+
* filed under that reason instead. A false scope negative teaches the relevance
|
|
31
|
+
* classifier something wrong; a missed one only leaves it untaught.
|
|
32
|
+
*
|
|
33
|
+
* Each phrase is a whole word or wording, never a fragment, so "slopes" is not
|
|
34
|
+
* read as "slop". Words that say none of this yield no code at all rather than
|
|
35
|
+
* the nearest guess.
|
|
36
|
+
*/
|
|
37
|
+
const WRITTEN_REASON_PHRASES: ReadonlyArray<readonly [ReferenceRejectionReasonCode, readonly string[]]> = [
|
|
38
|
+
["duplicate", [
|
|
39
|
+
"duplicate", "duplicates", "duplicated", "duplication", "dupe", "reprint", "reprinted",
|
|
40
|
+
"already have (it|this|that|the)", "already (got|covered|accepted|registered|ingested|indexed)",
|
|
41
|
+
"already in (our|the) (corpus|list|feed|knowledge base|library)", "same (source|piece|story) as",
|
|
42
|
+
]],
|
|
43
|
+
["unavailable", [
|
|
44
|
+
"paywall", "paywalled", "unavailable", "not available", "no longer (available|online|up|there|reachable)",
|
|
45
|
+
"behind a (paywall|login|subscription|sign in)", "(dead|broken|404) link", "link is (dead|broken)",
|
|
46
|
+
"(cannot|cant) (open|read|reach|access|download|retrieve)", "requires a (subscription|login|account|sign in)",
|
|
47
|
+
"taken down", "timed out", "page is gone",
|
|
48
|
+
]],
|
|
49
|
+
["provenance", [
|
|
50
|
+
"provenance", "unattributed", "unverified", "unverifiable", "untraceable",
|
|
51
|
+
"(cannot|cant) (verify|attribute)", "no (byline|author|citation|attribution|provenance)",
|
|
52
|
+
"(unknown|unclear|anonymous|missing) (author|source|origin|provenance)", "who wrote it",
|
|
53
|
+
]],
|
|
54
|
+
["low_quality", [
|
|
55
|
+
"(low|poor) quality", "poorly (written|sourced|argued|researched|edited)", "badly written", "sloppy",
|
|
56
|
+
"content farm", "clickbait", "seo (spam|bait|filler)", "slop", "junk", "spam", "no substance", "churnalism",
|
|
57
|
+
]],
|
|
58
|
+
["policy_exclusion", [
|
|
59
|
+
"policy exclusion", "(our|house|editorial) policy", "against (our )?policy",
|
|
60
|
+
"(policy|policies) (excludes?|forbids?|bars?|rules? out|says?)",
|
|
61
|
+
"(excluded|barred|ruled out|forbidden) by (our )?policy",
|
|
62
|
+
"we (dont|do not) (cover|run|publish|cite|take)", "not allowed", "disallowed", "prohibited",
|
|
63
|
+
"blocklist", "blocklisted", "blocked source",
|
|
64
|
+
]],
|
|
65
|
+
["out_of_scope", [
|
|
66
|
+
"out of scope", "outside (our|the) (scope|remit|coverage|beat|lane)", "not (in|within) (our|the) scope",
|
|
67
|
+
"off topic", "nothing to do with", "not (our|the) (beat|subject|topic|area|coverage)",
|
|
68
|
+
"not relevant", "no longer relevant", "irrelevant", "unrelated", "wrong (beat|subject|topic)",
|
|
69
|
+
]],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const WRITTEN_REASON_PATTERNS: ReadonlyArray<readonly [ReferenceRejectionReasonCode, RegExp]> =
|
|
73
|
+
WRITTEN_REASON_PHRASES.map(([code, phrases]) => [code, new RegExp(`\\b(?:${phrases.join("|")})\\b`)] as const);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The reason code a rejection is filed under when the editor gave none: what
|
|
77
|
+
* their own words plainly say, else "other".
|
|
78
|
+
*
|
|
79
|
+
* Words are a reason — a written sentence is what the editor actually gives
|
|
80
|
+
* now that there are no reason chips — but "other" is the honest answer for
|
|
81
|
+
* words that name no policy code, rather than a guess the sentence cannot
|
|
82
|
+
* support. Blank words are no reason at all, so this yields null and the
|
|
83
|
+
* caller refuses the rejection.
|
|
84
|
+
*/
|
|
85
|
+
export function rejectionReasonCodeForWrittenReason(writtenReason: unknown): ReferenceRejectionReasonCode | null {
|
|
86
|
+
const words = normalizeWrittenReason(writtenReason);
|
|
87
|
+
if (!words) return null;
|
|
88
|
+
for (const [code, pattern] of WRITTEN_REASON_PATTERNS) {
|
|
89
|
+
if (pattern.test(words)) return code;
|
|
90
|
+
}
|
|
91
|
+
return "other";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Lower case, apostrophes dropped ("don't" -> "dont") and every other gap a single space, so phrases match plain prose. */
|
|
95
|
+
function normalizeWrittenReason(value: unknown): string {
|
|
96
|
+
return String(value ?? "")
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.replace(/['\u2018\u2019]/g, "")
|
|
99
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
100
|
+
.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
25
103
|
export function normalizeReferenceCurationStatus(value: unknown, defaultStatus: ReferenceCurationStatus = "pending"): ReferenceCurationStatus {
|
|
26
104
|
const normalized = normalizePolicyToken(value ?? defaultStatus);
|
|
27
105
|
if (normalized === "accepted" || normalized === "accept" || normalized === "ready" || normalized === "trusted") return "accepted";
|
|
@@ -61,23 +139,17 @@ export function isEvidenceEligibleReference(reference: Pick<ReferenceRecord, "ve
|
|
|
61
139
|
&& normalizeReferenceCurationStatus(reference?.curationStatus) === "accepted";
|
|
62
140
|
}
|
|
63
141
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const sortedMessages = [...messages].sort((left, right) => String(right.createdAt ?? "").localeCompare(String(left.createdAt ?? "")));
|
|
68
|
-
for (const message of sortedMessages) {
|
|
69
|
-
const reason = reasonCodeFromMetadata(message.metadata);
|
|
70
|
-
if (reason) return reason;
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
142
|
+
/** The reason code of the Reference's latest vote (`curationReasonCode`), else one recorded in its metadata. */
|
|
143
|
+
export function referenceReasonCode(reference: Pick<ReferenceRecord, "metadata" | "curationReasonCode"> | null | undefined): ReferenceRejectionReasonCode | null {
|
|
144
|
+
return normalizeReferenceRejectionReasonCode(reference?.curationReasonCode) ?? reasonCodeFromMetadata(reference?.metadata);
|
|
73
145
|
}
|
|
74
146
|
|
|
75
|
-
export function scopeTrainingLabelForReference(reference: Pick<ReferenceRecord, "versionState" | "curationStatus" | "metadata"
|
|
147
|
+
export function scopeTrainingLabelForReference(reference: Pick<ReferenceRecord, "versionState" | "curationStatus" | "metadata" | "curationReasonCode"> | null | undefined): "positive" | "negative" | null {
|
|
76
148
|
if (!reference || reference.versionState !== "current") return null;
|
|
77
149
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
78
150
|
if (status === "accepted") return "positive";
|
|
79
151
|
if (status !== "rejected") return null;
|
|
80
|
-
const reasonCode = referenceReasonCode(reference
|
|
152
|
+
const reasonCode = referenceReasonCode(reference);
|
|
81
153
|
return reasonCode && SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(reasonCode) ? "negative" : null;
|
|
82
154
|
}
|
|
83
155
|
|
|
@@ -1,71 +1,35 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReferenceRecord, SemanticRelationRecord } from "./category-repository";
|
|
2
2
|
import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** The selected Reference's index is authoritative even when the global feed is truncated. */
|
|
4
|
+
/**
|
|
5
|
+
* The selected Reference's indexed predictions (the Cyclotron's relevance decisions). The Reference's own latest vote
|
|
6
|
+
* (`curationActorKind`, `curationReasonCode`, ...) is already on the Reference, so no Message is read.
|
|
7
|
+
*/
|
|
12
8
|
export async function loadReferenceReviewDetails(lineageId: string, read: {
|
|
13
|
-
relations: (direction: "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const [outgoing, incoming] = await Promise.all([read.relations("outgoing"), read.relations("incoming")]);
|
|
18
|
-
const relations = outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId);
|
|
19
|
-
const latest = incoming.filter((relation) => relation.predicate === "comment" && relation.relationState === "current"
|
|
20
|
-
&& relation.subjectKind === "message" && relation.objectKind === "reference" && relation.objectLineageId === lineageId
|
|
21
|
-
&& metadataRecord(relation.metadata).messageKind === "reference_curation")
|
|
22
|
-
.sort((a, b) => String(b.createdAt ?? b.importedAt ?? "").localeCompare(String(a.createdAt ?? a.importedAt ?? "")))[0];
|
|
23
|
-
if (!latest) return { relations, messages: [] };
|
|
24
|
-
const message = await read.message(latest.subjectId);
|
|
25
|
-
if (!message || message.messageKind !== "reference_curation") throw new Error("The latest reference review could not be loaded.");
|
|
26
|
-
const payload = (await read.payloads(message.id))
|
|
27
|
-
.filter((entry) => entry.attachment.role === "metadata" && entry.attachment.ownerKind === "message" && entry.attachment.ownerId === message.id)
|
|
28
|
-
.sort((a, b) => String(b.attachment.updatedAt ?? "").localeCompare(String(a.attachment.updatedAt ?? "")))[0];
|
|
29
|
-
if (!payload) throw new Error("The latest reference review metadata could not be loaded.");
|
|
30
|
-
if (payload.error) throw new Error(payload.error);
|
|
31
|
-
if (!payload.json || typeof payload.json !== "object" || Array.isArray(payload.json)) {
|
|
32
|
-
throw new Error("The latest reference review metadata is invalid.");
|
|
33
|
-
}
|
|
34
|
-
const hydrated = { ...message, metadata: payload.json };
|
|
35
|
-
const recordedLineage = metadataRecord(hydrated.metadata).referenceLineageId;
|
|
36
|
-
if (recordedLineage !== lineageId) throw new Error("The latest review metadata does not match this reference lineage.");
|
|
37
|
-
return { relations, messages: [hydrated] };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function latestReferenceReviews(messages: MessageRecord[]): Map<string, MessageRecord> {
|
|
41
|
-
const latest = new Map<string, MessageRecord>();
|
|
42
|
-
for (const message of messages) {
|
|
43
|
-
if (message.messageKind !== "reference_curation") continue;
|
|
44
|
-
const lineage = metadataRecord(message.metadata).referenceLineageId;
|
|
45
|
-
if (typeof lineage !== "string" || !lineage) continue;
|
|
46
|
-
if (!latest.has(lineage) || String(message.createdAt).localeCompare(String(latest.get(lineage)!.createdAt)) > 0) latest.set(lineage, message);
|
|
47
|
-
}
|
|
48
|
-
return latest;
|
|
9
|
+
relations: (direction: "outgoing") => Promise<SemanticRelationRecord[]>;
|
|
10
|
+
}): Promise<{ relations: SemanticRelationRecord[] }> {
|
|
11
|
+
const outgoing = await read.relations("outgoing");
|
|
12
|
+
return { relations: outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId) };
|
|
49
13
|
}
|
|
50
14
|
|
|
51
|
-
/** A later acceptance or archive
|
|
52
|
-
export function latestReferenceReviewReasons(
|
|
15
|
+
/** The reason code of each rejected Reference's latest vote. A later acceptance or archive replaced the vote, so it clears the reason. */
|
|
16
|
+
export function latestReferenceReviewReasons(references: readonly ReferenceRecord[]): Map<string, ReferenceRejectionReasonCode> {
|
|
53
17
|
const reasons = new Map<string, ReferenceRejectionReasonCode>();
|
|
54
|
-
for (const
|
|
55
|
-
|
|
56
|
-
const reason =
|
|
57
|
-
if (reason) reasons.set(
|
|
18
|
+
for (const reference of references) {
|
|
19
|
+
if (!reference.curationActorKind || reference.curationStatus !== "rejected") continue;
|
|
20
|
+
const reason = normalizeReferenceRejectionReasonCode(reference.curationReasonCode);
|
|
21
|
+
if (reason) reasons.set(reference.lineageId ?? reference.id, reason);
|
|
58
22
|
}
|
|
59
23
|
return reasons;
|
|
60
24
|
}
|
|
61
25
|
|
|
62
|
-
/**
|
|
63
|
-
export function latestReferenceReviewSections(
|
|
26
|
+
/** The Section a person explicitly recorded on a Reference's latest vote when accepting it, never a prediction. */
|
|
27
|
+
export function latestReferenceReviewSections(references: readonly ReferenceRecord[]): Map<string, string> {
|
|
64
28
|
const sections = new Map<string, string>();
|
|
65
|
-
for (const
|
|
66
|
-
|
|
67
|
-
const sectionId =
|
|
68
|
-
if (sectionId) sections.set(
|
|
29
|
+
for (const reference of references) {
|
|
30
|
+
if (!reference.curationActorKind || reference.curationStatus !== "accepted") continue;
|
|
31
|
+
const sectionId = reference.curationSectionId?.trim() ?? "";
|
|
32
|
+
if (sectionId) sections.set(reference.lineageId ?? reference.id, sectionId);
|
|
69
33
|
}
|
|
70
34
|
return sections;
|
|
71
35
|
}
|
|
@@ -149,11 +149,15 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
|
|
|
149
149
|
* any other label, or a review without a label, rejects with the reason code,
|
|
150
150
|
* which the existing scope-training rule turns back into a cyclotron label
|
|
151
151
|
* (out_of_scope and policy_exclusion) or none.
|
|
152
|
+
*
|
|
153
|
+
* A rejection must say why, but either way of saying it will do: a reason code,
|
|
154
|
+
* or the editor's own words, which the curation Lambda reads for the code they
|
|
155
|
+
* name. Only a rejection with neither is refused.
|
|
152
156
|
*/
|
|
153
157
|
export function curationForRelevanceReview(decision: RelevanceDecisionView, review: RelevanceReview): RelevanceCuration {
|
|
154
158
|
const accepted = review.label !== null && review.label === decision.positiveLabel;
|
|
155
|
-
if (!accepted && !review.reasonCode) {
|
|
156
|
-
throw new Error("A review that does not accept the reference needs a reason code.");
|
|
159
|
+
if (!accepted && !review.reasonCode && !review.explanation?.trim()) {
|
|
160
|
+
throw new Error("A review that does not accept the reference needs a reason code, or a written reason.");
|
|
157
161
|
}
|
|
158
162
|
return {
|
|
159
163
|
action: accepted ? "accept" : "reject",
|
|
@@ -17,6 +17,15 @@ export type TrailVersion = {
|
|
|
17
17
|
updatedAt: string | null;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/** One stage move of the story, read from the latest Item version's `metadata.stageMoves` (not from Messages). */
|
|
21
|
+
export type TrailStageMove = {
|
|
22
|
+
at: string;
|
|
23
|
+
from: string | null;
|
|
24
|
+
to: string;
|
|
25
|
+
by: string | null;
|
|
26
|
+
reason: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
20
29
|
export type TrailAssignmentEvent = {
|
|
21
30
|
id: string;
|
|
22
31
|
eventType: string;
|
|
@@ -89,12 +98,29 @@ export type StoryReportingTrail = {
|
|
|
89
98
|
threads: TrailThread[];
|
|
90
99
|
references: TrailReference[];
|
|
91
100
|
attachments: TrailAttachment[];
|
|
101
|
+
stageMoves: TrailStageMove[];
|
|
92
102
|
};
|
|
93
103
|
|
|
94
|
-
export const EMPTY_STORY_REPORTING_TRAIL: StoryReportingTrail = { versions: [], assignments: [], threads: [], references: [], attachments: [] };
|
|
104
|
+
export const EMPTY_STORY_REPORTING_TRAIL: StoryReportingTrail = { versions: [], assignments: [], threads: [], references: [], attachments: [], stageMoves: [] };
|
|
105
|
+
|
|
106
|
+
/** The stage moves recorded on an Item version's metadata, oldest first. Anything that is not a well-formed move is skipped. */
|
|
107
|
+
export function stageMovesOf(metadata: unknown): TrailStageMove[] {
|
|
108
|
+
let parsed: unknown = metadata;
|
|
109
|
+
if (typeof metadata === "string") {
|
|
110
|
+
try { parsed = JSON.parse(metadata); } catch { return []; }
|
|
111
|
+
}
|
|
112
|
+
const moves = typeof parsed === "object" && parsed ? (parsed as Record<string, unknown>).stageMoves : null;
|
|
113
|
+
if (!Array.isArray(moves)) return [];
|
|
114
|
+
return moves.flatMap(entry => {
|
|
115
|
+
if (typeof entry !== "object" || !entry) return [];
|
|
116
|
+
const move = entry as Record<string, unknown>;
|
|
117
|
+
if (typeof move.at !== "string" || typeof move.to !== "string") return [];
|
|
118
|
+
return [{ at: move.at, from: typeof move.from === "string" ? move.from : null, to: move.to, by: typeof move.by === "string" ? move.by : null, reason: typeof move.reason === "string" ? move.reason : null }];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
95
121
|
|
|
96
122
|
export function storyReportingTrailIsEmpty(trail: StoryReportingTrail): boolean {
|
|
97
|
-
return trail.assignments.length === 0 && trail.threads.length === 0 && trail.references.length === 0 && trail.attachments.length === 0 && trail.versions.length <= 1;
|
|
123
|
+
return trail.assignments.length === 0 && trail.threads.length === 0 && trail.stageMoves.length === 0 && trail.references.length === 0 && trail.attachments.length === 0 && trail.versions.length <= 1;
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
export type StoryFeedEntry =
|
package/package.json
CHANGED
|
@@ -80,7 +80,16 @@
|
|
|
80
80
|
"./components/automation-run-now": "./components/automation-run-now.tsx",
|
|
81
81
|
"./components/automation-view": "./components/automation-view.tsx",
|
|
82
82
|
"./components/blog-page-background": "./components/blog-page-background.tsx",
|
|
83
|
+
"./components/conversation-context": "./components/conversation-context.tsx",
|
|
84
|
+
"./components/conversation-provider": "./components/conversation-provider.tsx",
|
|
85
|
+
"./components/conversation-thread": "./components/conversation-thread.tsx",
|
|
86
|
+
"./components/conversations-memory-source": "./components/conversations-memory-source.ts",
|
|
87
|
+
"./components/conversations-network-source": "./components/conversations-network-source.ts",
|
|
88
|
+
"./components/conversations-page": "./components/conversations-page.tsx",
|
|
89
|
+
"./components/conversations-source": "./components/conversations-source.ts",
|
|
90
|
+
"./components/conversations-view": "./components/conversations-view.tsx",
|
|
83
91
|
"./components/curation-delegation-client": "./components/curation-delegation-client.ts",
|
|
92
|
+
"./components/decision-control": "./components/decision-control.tsx",
|
|
84
93
|
"./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
|
|
85
94
|
"./components/editorial-checks-admin-panel": "./components/editorial-checks-admin-panel.tsx",
|
|
86
95
|
"./components/editorial-finding-card": "./components/editorial-finding-card.tsx",
|
|
@@ -146,6 +155,7 @@
|
|
|
146
155
|
"./components/reference-add-entry": "./components/reference-add-entry.tsx",
|
|
147
156
|
"./components/reference-capture-client": "./components/reference-capture-client.ts",
|
|
148
157
|
"./components/reference-capture-panel": "./components/reference-capture-panel.tsx",
|
|
158
|
+
"./components/reference-conversation": "./components/reference-conversation.tsx",
|
|
149
159
|
"./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
|
|
150
160
|
"./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
|
|
151
161
|
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
@@ -160,7 +170,6 @@
|
|
|
160
170
|
"./components/reference-stories": "./components/reference-stories.tsx",
|
|
161
171
|
"./components/references-page-loading": "./components/references-page-loading.tsx",
|
|
162
172
|
"./components/references-prefetch": "./components/references-prefetch.ts",
|
|
163
|
-
"./components/review-rating-grid": "./components/review-rating-grid.tsx",
|
|
164
173
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
165
174
|
"./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
|
|
166
175
|
"./components/staging-banner": "./components/staging-banner.tsx",
|
|
@@ -189,6 +198,7 @@
|
|
|
189
198
|
"./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
|
|
190
199
|
"./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
|
|
191
200
|
"./components/use-console-visibility": "./components/use-console-visibility.ts",
|
|
201
|
+
"./components/use-conversation": "./components/use-conversation.ts",
|
|
192
202
|
"./components/use-editorial-checks": "./components/use-editorial-checks.ts",
|
|
193
203
|
"./components/use-media-query": "./components/use-media-query.ts",
|
|
194
204
|
"./components/use-near-viewport": "./components/use-near-viewport.ts",
|
|
@@ -229,6 +239,8 @@
|
|
|
229
239
|
"./lib/content-repository": "./lib/content-repository.ts",
|
|
230
240
|
"./lib/content-source-context": "./lib/content-source-context.ts",
|
|
231
241
|
"./lib/content-types": "./lib/content-types.ts",
|
|
242
|
+
"./lib/conversation-feed": "./lib/conversation-feed.ts",
|
|
243
|
+
"./lib/conversation": "./lib/conversation.ts",
|
|
232
244
|
"./lib/curation-client": "./lib/curation-client.ts",
|
|
233
245
|
"./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
|
|
234
246
|
"./lib/curation-routes": "./lib/curation-routes.ts",
|
|
@@ -342,6 +354,7 @@
|
|
|
342
354
|
"./lib/reference-capture": "./lib/reference-capture.ts",
|
|
343
355
|
"./lib/reference-display": "./lib/reference-display.ts",
|
|
344
356
|
"./lib/reference-feed-cache": "./lib/reference-feed-cache.ts",
|
|
357
|
+
"./lib/reference-feed-keys": "./lib/reference-feed-keys.ts",
|
|
345
358
|
"./lib/reference-feed-ranking": "./lib/reference-feed-ranking.ts",
|
|
346
359
|
"./lib/reference-policy": "./lib/reference-policy.ts",
|
|
347
360
|
"./lib/reference-preview": "./lib/reference-preview.ts",
|
|
@@ -504,7 +517,7 @@
|
|
|
504
517
|
"optional": true
|
|
505
518
|
}
|
|
506
519
|
},
|
|
507
|
-
"version": "5.
|
|
520
|
+
"version": "5.58.0",
|
|
508
521
|
"dependencies": {
|
|
509
522
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
510
523
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { CheckIcon, CircleCheckIcon, CircleXIcon, Loader2Icon, XIcon } from "lucide-react";
|
|
4
|
-
import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
|
|
5
|
-
import { cn } from "../lib/utils";
|
|
6
|
-
import type { CardPrediction, ReviewChoice } from "./reference-review-card";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The rating area of a card: one grid, three columns (label | Accept | Reject), and the two rows share its columns exactly.
|
|
10
|
-
* Cyclotron [ Accept 96% ] [ Reject ] read only; the predicted side is highlighted with its confidence
|
|
11
|
-
* [====== confidence bar, centred at 50% ======]
|
|
12
|
-
* You [ Accept ] [ Reject ] the real buttons, each directly under its matching cell
|
|
13
|
-
* Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
|
|
14
|
-
* green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
|
|
15
|
-
* filled. Flat tones and rounded shapes only, no borders.
|
|
16
|
-
* The grid sizes itself to its own width (a container query), not the window's: on a narrow card the label column shrinks to its
|
|
17
|
-
* word, the padding tightens, and the confidence wraps under "Accept" instead of spilling out of its cell.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
|
|
21
|
-
|
|
22
|
-
/** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
|
|
23
|
-
export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
|
|
24
|
-
const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
|
|
25
|
-
return prediction.choice === "accept" ? confidence : 1 - confidence;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
|
|
29
|
-
export function confidenceFillPercent(probability: number): number {
|
|
30
|
-
return Math.min(100, Math.abs(probability - 0.5) * 2 * 100);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
|
|
34
|
-
|
|
35
|
-
const TONE = {
|
|
36
|
-
match: "bg-emerald-600/20 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100",
|
|
37
|
-
miss: "bg-red-600/20 text-red-900 dark:bg-red-400/20 dark:text-red-100",
|
|
38
|
-
} as const;
|
|
39
|
-
const FILL = { match: "bg-emerald-600 dark:bg-emerald-400", miss: "bg-red-600 dark:bg-red-400", neutral: "bg-[var(--primary)]" } as const;
|
|
40
|
-
|
|
41
|
-
export function RatingGrid({
|
|
42
|
-
answer,
|
|
43
|
-
decided = answer,
|
|
44
|
-
disabled = false,
|
|
45
|
-
disabledReason = null,
|
|
46
|
-
feedback = { phase: "idle" },
|
|
47
|
-
note = null,
|
|
48
|
-
onChoose,
|
|
49
|
-
prediction,
|
|
50
|
-
showPrediction = true,
|
|
51
|
-
size = "compact",
|
|
52
|
-
statusOverlay = false,
|
|
53
|
-
surface = "footer",
|
|
54
|
-
}: {
|
|
55
|
-
/** The side the buttons show as chosen (it may be a pending choice that is still saving). */
|
|
56
|
-
answer: ReviewChoice | null;
|
|
57
|
-
/** The saved human answer, which the Match / Miss verdict compares against. */
|
|
58
|
-
decided?: ReviewChoice | null;
|
|
59
|
-
disabled?: boolean;
|
|
60
|
-
disabledReason?: string | null;
|
|
61
|
-
feedback?: RatingFeedback;
|
|
62
|
-
/** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
|
|
63
|
-
note?: string | null;
|
|
64
|
-
onChoose: (choice: ReviewChoice) => void;
|
|
65
|
-
prediction: CardPrediction;
|
|
66
|
-
/** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
|
|
67
|
-
showPrediction?: boolean;
|
|
68
|
-
size?: "compact" | "large";
|
|
69
|
-
/** Lay the Saved / note line over the bottom padding of the nearest positioned ancestor, so it adds no height under the buttons. */
|
|
70
|
-
statusOverlay?: boolean;
|
|
71
|
-
/** The tone the grid sits on, so the bar's centre notch matches it. */
|
|
72
|
-
surface?: "footer" | "card";
|
|
73
|
-
}) {
|
|
74
|
-
const ready = prediction.state === "ready" ? prediction : null;
|
|
75
|
-
const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
|
|
76
|
-
const probability = ready ? predictionAcceptProbability(ready) : 0.5;
|
|
77
|
-
const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
|
|
78
|
-
const leansAccept = probability >= 0.5;
|
|
79
|
-
const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
|
|
80
|
-
const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
|
|
81
|
-
const label = size === "large" ? "text-sm" : "text-xs";
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<div
|
|
85
|
-
className={cn(
|
|
86
|
-
"@container/rating grid gap-x-2 gap-y-2",
|
|
87
|
-
showPrediction ? "grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)] @max-[17rem]/rating:gap-x-1.5" : "grid-cols-2",
|
|
88
|
-
)}
|
|
89
|
-
data-rating-grid
|
|
90
|
-
>
|
|
91
|
-
{showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
|
|
92
|
-
<span className={cn("self-center font-semibold", label)}>Cyclotron</span>
|
|
93
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
94
|
-
const predicted = ready?.choice === choice;
|
|
95
|
-
const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
|
|
96
|
-
return (
|
|
97
|
-
<span
|
|
98
|
-
className={cn(
|
|
99
|
-
"flex min-w-0 flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center @max-[17rem]/rating:px-1",
|
|
100
|
-
cell,
|
|
101
|
-
!predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
|
|
102
|
-
predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
|
|
103
|
-
predicted && verdict && cn("font-semibold", TONE[verdict]),
|
|
104
|
-
)}
|
|
105
|
-
data-cyclotron-choice={choice}
|
|
106
|
-
data-predicted={predicted || undefined}
|
|
107
|
-
data-verdict={predicted && verdict ? verdict : undefined}
|
|
108
|
-
key={choice}
|
|
109
|
-
>
|
|
110
|
-
{/* Wraps rather than overflows: when the cell is narrow, the confidence moves onto its own line. */}
|
|
111
|
-
<span className="flex max-w-full flex-wrap items-center justify-center gap-x-1.5 leading-tight">
|
|
112
|
-
<span className="inline-flex items-center gap-1">
|
|
113
|
-
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
|
|
114
|
-
{WORD[choice]}
|
|
115
|
-
</span>
|
|
116
|
-
{/* The confidence and the verdict share one line, so a vote never makes the cell (or the card) taller. */}
|
|
117
|
-
{predicted && (confidenceText || verdict) ? (
|
|
118
|
-
<small className="inline-flex items-center gap-1 whitespace-nowrap text-xs">
|
|
119
|
-
{confidenceText ? <span className="font-normal opacity-80" data-cyclotron-confidence>{confidenceText}</span> : null}
|
|
120
|
-
{confidenceText && verdict ? <span aria-hidden="true" className="opacity-60">·</span> : null}
|
|
121
|
-
{verdict ? <span className="font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</span> : null}
|
|
122
|
-
</small>
|
|
123
|
-
) : null}
|
|
124
|
-
</span>
|
|
125
|
-
{predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
126
|
-
</span>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
129
|
-
<div className="col-span-2 col-start-2 flex flex-col justify-center" data-confidence>
|
|
130
|
-
<div
|
|
131
|
-
aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
|
|
132
|
-
className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
|
|
133
|
-
data-confidence-bar
|
|
134
|
-
data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
|
|
135
|
-
role="img"
|
|
136
|
-
>
|
|
137
|
-
{ready ? (
|
|
138
|
-
<span
|
|
139
|
-
className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
|
|
140
|
-
data-confidence-fill
|
|
141
|
-
data-fill-percent={Math.round(half * 2)}
|
|
142
|
-
style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
|
|
143
|
-
/>
|
|
144
|
-
) : null}
|
|
145
|
-
<span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
|
|
146
|
-
</div>
|
|
147
|
-
{/* Screen-reader status only: a visible line under the bar would push it off the middle between the Cyclotron and You rows. */}
|
|
148
|
-
<small className="sr-only" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
|
|
149
|
-
{prediction.state === "ready" ? "" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
150
|
-
</small>
|
|
151
|
-
</div>
|
|
152
|
-
</div> : null}
|
|
153
|
-
|
|
154
|
-
<div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
|
|
155
|
-
{showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
|
|
156
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
157
|
-
const on = answer === choice;
|
|
158
|
-
const Icon = choice === "accept" ? CheckIcon : XIcon;
|
|
159
|
-
return (
|
|
160
|
-
<button
|
|
161
|
-
aria-disabled={disabled ? true : undefined}
|
|
162
|
-
aria-pressed={on}
|
|
163
|
-
className={cn(
|
|
164
|
-
"inline-flex min-w-0 items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring @max-[17rem]/rating:gap-1 @max-[17rem]/rating:px-1",
|
|
165
|
-
cell,
|
|
166
|
-
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
|
|
167
|
-
disabled && !on && "opacity-60",
|
|
168
|
-
)}
|
|
169
|
-
data-review-choice={choice}
|
|
170
|
-
key={choice}
|
|
171
|
-
onClick={() => onChoose(choice)}
|
|
172
|
-
type="button"
|
|
173
|
-
>
|
|
174
|
-
<Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
|
|
175
|
-
{WORD[choice]}
|
|
176
|
-
</button>
|
|
177
|
-
);
|
|
178
|
-
})}
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
<div className={cn("flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground", statusOverlay ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : cn("min-h-5", showPrediction ? "col-span-3" : "col-span-2"))} data-feed-saved role="status">
|
|
182
|
-
{note ? <span data-feed-reason>{note}</span> : null}
|
|
183
|
-
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
184
|
-
{feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
|
|
185
|
-
</div>
|
|
186
|
-
{disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
|
|
187
|
-
{feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
}
|