feat(feedback): add durable message feedback backend
This commit is contained in:
380
packages/feedback/message-feedback/src/index.ts
Normal file
380
packages/feedback/message-feedback/src/index.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Durable, lifecycle-bound feedback for finalized assistant messages.
|
||||
* @module @deepseek-ai/dsh-message-feedback
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import s from '@deepseek-ai/schemastery'
|
||||
import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
|
||||
import { messageFeedbackDomainSpec } from './spec.ts'
|
||||
import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
|
||||
import type {
|
||||
MessageFeedbackDeleteRequest,
|
||||
MessageFeedbackDeleteResult,
|
||||
MessageFeedbackDeleteValue,
|
||||
MessageFeedbackFailure,
|
||||
MessageFeedbackItem,
|
||||
MessageFeedbackListRequest,
|
||||
MessageFeedbackListResult,
|
||||
MessageFeedbackListValue,
|
||||
MessageFeedbackNoteBlank,
|
||||
MessageFeedbackNoteTooLarge,
|
||||
MessageFeedbackPutRequest,
|
||||
MessageFeedbackPutResult,
|
||||
MessageFeedbackRejected,
|
||||
MessageFeedbackSessionNotFound,
|
||||
MessageFeedbackSuccess,
|
||||
MessageFeedbackVersion,
|
||||
MessageFeedbackVersionConflict,
|
||||
} from './types.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export {
|
||||
messageFeedbackDomainSpec,
|
||||
messageFeedbackItemSchema,
|
||||
messageFeedbackRatingSchema,
|
||||
messageFeedbackRowSchema,
|
||||
messageFeedbackSessionIdentitySchema,
|
||||
messageFeedbackVersionSchema,
|
||||
} from './spec.ts'
|
||||
export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts'
|
||||
|
||||
/** Required deployment policy for optional notes. */
|
||||
export interface Config {
|
||||
/** Maximum UTF-8 byte length accepted for one note. */
|
||||
readonly maxNoteBytes: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
messageFeedback: MessageFeedbackService
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable empty list reused only as an input to caller-owned copying. */
|
||||
const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([])
|
||||
|
||||
/** Validate the one deployment-varying limit at the configuration boundary. */
|
||||
function resolveMaxNoteBytes(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new TypeError(
|
||||
`message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`,
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Copy and freeze one item before it crosses the service boundary. */
|
||||
function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem {
|
||||
return Object.freeze({
|
||||
messageId: item.messageId,
|
||||
rating: item.rating,
|
||||
...(item.note === undefined ? {} : { note: item.note }),
|
||||
version: item.version,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
/** Copy and freeze a list response. */
|
||||
function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue {
|
||||
return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) })
|
||||
}
|
||||
|
||||
/** Build a frozen success branch. */
|
||||
function success<T>(value: T): MessageFeedbackSuccess<T> {
|
||||
return Object.freeze({ ok: true, value })
|
||||
}
|
||||
|
||||
/** Build a frozen business-failure branch. */
|
||||
function rejected<E extends MessageFeedbackFailure>(error: E): MessageFeedbackRejected<E> {
|
||||
return Object.freeze({ ok: false, error: Object.freeze(error) })
|
||||
}
|
||||
|
||||
/** Project the Session fields that distinguish one persisted log lifecycle. */
|
||||
function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity {
|
||||
return Object.freeze({
|
||||
createdAt: header.createdAt,
|
||||
...(header.cwd === undefined ? {} : { cwd: header.cwd }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a stored row belongs to the inspected Session lifecycle. */
|
||||
function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean {
|
||||
return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd
|
||||
}
|
||||
|
||||
/** Whether two observations name the same persisted Session lifecycle. */
|
||||
function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean {
|
||||
return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd
|
||||
}
|
||||
|
||||
/** Freeze the replacement row so storage-domain never exposes mutable aliases. */
|
||||
function rowSnapshot(
|
||||
session: MessageFeedbackSessionIdentity,
|
||||
items: readonly MessageFeedbackItem[],
|
||||
): MessageFeedbackRow {
|
||||
const copiedItems = items.map(snapshotItem)
|
||||
Object.freeze(copiedItems)
|
||||
return Object.freeze({
|
||||
session,
|
||||
items: copiedItems,
|
||||
})
|
||||
}
|
||||
|
||||
/** Generate an opaque equality token for one material mutation. */
|
||||
function nextVersion(): MessageFeedbackVersion {
|
||||
return randomUUID() as MessageFeedbackVersion
|
||||
}
|
||||
|
||||
/** Session inspection result that keeps absence inside the business union. */
|
||||
type KnownSession =
|
||||
| MessageFeedbackSuccess<SessionInspection>
|
||||
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
|
||||
|
||||
/** Validated note or one explicit request failure. */
|
||||
type ResolvedNote =
|
||||
| MessageFeedbackSuccess<string | undefined>
|
||||
| MessageFeedbackRejected<MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge>
|
||||
|
||||
/**
|
||||
* Storage-domain sidecar service. It inspects persisted Session history and
|
||||
* never creates or resumes an Agent or Session.
|
||||
*/
|
||||
export class MessageFeedbackService extends GatewayService {
|
||||
static inject = ['storageDomain', 'sessionPersistence', 'sessions']
|
||||
|
||||
/** Loader validation for the required note-size policy. */
|
||||
static Config: s<Config> = s.object({
|
||||
maxNoteBytes: s.number().step(1).min(1).required(),
|
||||
})
|
||||
|
||||
private readonly maxNoteBytes: number
|
||||
private table?: KvTable<SessionId, MessageFeedbackRow>
|
||||
private readonly operationTails = new Map<SessionId, Promise<void>>()
|
||||
|
||||
/**
|
||||
* @param ctx - Host context carrying persistence and the storage-domain form.
|
||||
* @param config - Required note-size policy.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'messageFeedback')
|
||||
this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes)
|
||||
}
|
||||
|
||||
/** Open and own the one message-feedback sidecar domain. */
|
||||
protected async [Service.init](): Promise<void> {
|
||||
const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec)
|
||||
this.ctx.effect(() => () => domain.close(), 'message-feedback.domainClose')
|
||||
this.table = domain.table('sessions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read feedback belonging to the current persisted Session lifecycle.
|
||||
* A stale row from a reused Session id is invisible.
|
||||
* @param request - Session identity to inspect and list.
|
||||
* @returns current immutable items or `session-not-found`.
|
||||
*/
|
||||
@Remote('list')
|
||||
async list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult> {
|
||||
const known = await this.inspectSession(request.sessionId)
|
||||
if (!known.ok) return known
|
||||
const row = this.requireTable().get(request.sessionId)
|
||||
const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS
|
||||
return success(snapshotList(items))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace feedback for one derived append-origin assistant
|
||||
* message. An exact desired-value retry returns the stored item before its
|
||||
* stale or `null` version is considered a conflict.
|
||||
* @param request - target, desired value, and observed item version.
|
||||
* @returns the committed item or an explicit business failure.
|
||||
*/
|
||||
@Remote('put')
|
||||
put(request: MessageFeedbackPutRequest): Promise<MessageFeedbackPutResult> {
|
||||
const note = this.resolveNote(request.note)
|
||||
if (!note.ok) return Promise.resolve(note)
|
||||
return this.enqueue(request.sessionId, async () => {
|
||||
const known = await this.inspectSession(request.sessionId)
|
||||
if (!known.ok) return known
|
||||
if (!this.hasFeedbackTarget(known.value, request.messageId)) {
|
||||
return rejected({
|
||||
code: 'target-not-found',
|
||||
sessionId: request.sessionId,
|
||||
messageId: request.messageId,
|
||||
})
|
||||
}
|
||||
|
||||
const durable = await this.ensureTargetDurable(known.value)
|
||||
if (!sameHeaderIdentity(durable.meta, known.value.meta)
|
||||
|| !this.hasFeedbackTarget(durable, request.messageId)) {
|
||||
return rejected({
|
||||
code: 'target-not-found',
|
||||
sessionId: request.sessionId,
|
||||
messageId: request.messageId,
|
||||
})
|
||||
}
|
||||
|
||||
const table = this.requireTable()
|
||||
const stored = table.get(request.sessionId)
|
||||
const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined
|
||||
const items = current?.items ?? EMPTY_ITEMS
|
||||
const index = items.findIndex(item => item.messageId === request.messageId)
|
||||
const existing = items[index]
|
||||
if (existing !== undefined
|
||||
&& existing.rating === request.rating
|
||||
&& existing.note === note.value) {
|
||||
return success(snapshotItem(existing))
|
||||
}
|
||||
if (request.ifVersion !== (existing?.version ?? null)) {
|
||||
return rejected(this.versionConflict(request, existing?.version ?? null))
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const item = snapshotItem({
|
||||
messageId: request.messageId,
|
||||
rating: request.rating,
|
||||
...(note.value === undefined ? {} : { note: note.value }),
|
||||
version: nextVersion(),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt),
|
||||
})
|
||||
const nextItems = [...items]
|
||||
if (index === -1) nextItems.push(item)
|
||||
else nextItems[index] = item
|
||||
await table.put(
|
||||
request.sessionId,
|
||||
rowSnapshot(identityOf(durable.meta), nextItems),
|
||||
)
|
||||
return success(snapshotItem(item))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one feedback item. Absence is successful regardless of the
|
||||
* supplied version; an existing item requires an exact version match.
|
||||
* @param request - Session, message, and observed item version.
|
||||
* @returns the stable absent postcondition, or an explicit failure.
|
||||
*/
|
||||
@Remote('delete')
|
||||
delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult> {
|
||||
return this.enqueue(request.sessionId, async () => {
|
||||
const known = await this.inspectSession(request.sessionId)
|
||||
if (!known.ok) return known
|
||||
|
||||
const table = this.requireTable()
|
||||
const stored = table.get(request.sessionId)
|
||||
const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined
|
||||
const items = current?.items ?? EMPTY_ITEMS
|
||||
const existing = items.find(item => item.messageId === request.messageId)
|
||||
if (existing === undefined) {
|
||||
return success<MessageFeedbackDeleteValue>(Object.freeze({ absent: true }))
|
||||
}
|
||||
if (request.ifVersion !== existing.version) {
|
||||
return rejected(this.versionConflict(request, existing.version))
|
||||
}
|
||||
|
||||
await table.put(
|
||||
request.sessionId,
|
||||
rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)),
|
||||
)
|
||||
return success<MessageFeedbackDeleteValue>(Object.freeze({ absent: true }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a live owner directly; otherwise use the storage catalog as the
|
||||
* existence authority before inspecting the log. Inspection failures for a
|
||||
* catalogued Session remain infrastructure failures rather than being
|
||||
* guessed into the business `session-not-found` branch.
|
||||
*/
|
||||
private async inspectSession(sessionId: SessionId): Promise<KnownSession> {
|
||||
if (this.ctx.sessions.get(sessionId) === undefined) {
|
||||
const snapshots = await this.ctx.sessionPersistence.listSnapshots()
|
||||
if (!snapshots.some(snapshot => snapshot.header.id === sessionId)) {
|
||||
return rejected({ code: 'session-not-found', sessionId })
|
||||
}
|
||||
}
|
||||
return success(await this.ctx.sessionPersistence.inspect(sessionId))
|
||||
}
|
||||
|
||||
/** Require the exact finalized append-origin assistant message projection. */
|
||||
private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean {
|
||||
return inspection.events.some((event) => {
|
||||
if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false
|
||||
const message = deriveEventMessage(event)
|
||||
return message?.role === 'assistant' && message.id === messageId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the target log prefix behind a durability barrier before its sidecar.
|
||||
* A live owner flushes through the SessionStore's canonical checkpoint; a
|
||||
* cold owner is re-read from the physical durable prefix.
|
||||
*/
|
||||
private async ensureTargetDurable(inspection: SessionInspection): Promise<SessionInspection> {
|
||||
const live = this.ctx.sessions.get(inspection.meta.id)
|
||||
if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) {
|
||||
if (!(await this.ctx.sessions.flush(live))) {
|
||||
throw new Error(
|
||||
`message-feedback: no durability listener participated for live session '${inspection.meta.id}'`,
|
||||
)
|
||||
}
|
||||
return inspection
|
||||
}
|
||||
return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0)
|
||||
}
|
||||
|
||||
/** Validate optional-note semantics and the configured complete UTF-8 byte bound. */
|
||||
private resolveNote(note: string | undefined): ResolvedNote {
|
||||
if (note === undefined) return success(undefined)
|
||||
if (note.trim().length === 0) return rejected({ code: 'note-blank' })
|
||||
const actualBytes = Buffer.byteLength(note, 'utf8')
|
||||
if (actualBytes > this.maxNoteBytes) {
|
||||
return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes })
|
||||
}
|
||||
return success(note)
|
||||
}
|
||||
|
||||
/** Build a conflict branch without exposing an orderable version. */
|
||||
private versionConflict(
|
||||
request: Pick<MessageFeedbackPutRequest, 'sessionId' | 'messageId' | 'ifVersion'>,
|
||||
actual: MessageFeedbackVersion | null,
|
||||
): MessageFeedbackVersionConflict {
|
||||
return {
|
||||
code: 'version-conflict',
|
||||
sessionId: request.sessionId,
|
||||
messageId: request.messageId,
|
||||
expected: request.ifVersion,
|
||||
actual,
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue a complete read/compare/write mutation behind this Session's prior mutation. */
|
||||
private enqueue<T>(sessionId: SessionId, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.operationTails.get(sessionId) ?? Promise.resolve()
|
||||
const result = previous.then(operation)
|
||||
const tail = result.then(() => undefined, () => undefined)
|
||||
this.operationTails.set(sessionId, tail)
|
||||
return result.finally(() => {
|
||||
if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve the initialized durable table or fail a broken service lifecycle. */
|
||||
private requireTable(): KvTable<SessionId, MessageFeedbackRow> {
|
||||
if (this.table === undefined) {
|
||||
throw new Error('message-feedback: durable domain is not initialized')
|
||||
}
|
||||
return this.table
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageFeedbackService
|
||||
27
packages/feedback/message-feedback/src/invariant.ts
Normal file
27
packages/feedback/message-feedback/src/invariant.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-message-feedback'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'message-feedback-invariant'
|
||||
/** Services required before the companion can reserve and check package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the private typed writer owns current row mutations,
|
||||
* the domain schema validates rows on reopen, and no second authority exists.
|
||||
*/
|
||||
const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] })
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
88
packages/feedback/message-feedback/src/spec.ts
Normal file
88
packages/feedback/message-feedback/src/spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Durable storage-domain declaration for lifecycle-bound message feedback.
|
||||
* @module @deepseek-ai/dsh-message-feedback/src/spec
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion } from './types.ts'
|
||||
|
||||
const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
|
||||
/** Runtime schema for the closed rating vocabulary. */
|
||||
export const messageFeedbackRatingSchema = z.union([
|
||||
z.literal('positive'),
|
||||
z.literal('negative'),
|
||||
]) satisfies z.ZodType<MessageFeedbackRating>
|
||||
|
||||
/** Runtime schema for one opaque item version stored on disk. */
|
||||
export const messageFeedbackVersionSchema = z.uuid()
|
||||
.transform(value => value as MessageFeedbackVersion)
|
||||
|
||||
/** Runtime schema for one current feedback item. */
|
||||
export const messageFeedbackItemSchema = z.object({
|
||||
messageId: z.string().min(1).transform(value => value as MessageId),
|
||||
rating: messageFeedbackRatingSchema,
|
||||
note: z.string().refine(note => note.trim().length > 0, {
|
||||
message: 'message feedback note must contain a non-whitespace character',
|
||||
}).optional(),
|
||||
version: messageFeedbackVersionSchema,
|
||||
createdAt: nonNegativeSafeInteger,
|
||||
updatedAt: nonNegativeSafeInteger,
|
||||
}).refine(item => item.updatedAt >= item.createdAt, {
|
||||
path: ['updatedAt'],
|
||||
message: 'message feedback updatedAt must not precede createdAt',
|
||||
}) as unknown as z.ZodType<MessageFeedbackItem>
|
||||
|
||||
/** Persisted Session fields that fence a sidecar row to one log lifecycle. */
|
||||
export const messageFeedbackSessionIdentitySchema = z.object({
|
||||
createdAt: nonNegativeSafeInteger,
|
||||
cwd: z.string().optional(),
|
||||
})
|
||||
|
||||
/** Persisted lifecycle identity inferred from its durable schema. */
|
||||
export type MessageFeedbackSessionIdentity = z.infer<typeof messageFeedbackSessionIdentitySchema>
|
||||
|
||||
/**
|
||||
* One whole-Session sidecar. Duplicate message ids would make item lookup
|
||||
* ambiguous; duplicate versions would break their independent identity.
|
||||
*/
|
||||
export const messageFeedbackRowSchema = z.object({
|
||||
session: messageFeedbackSessionIdentitySchema,
|
||||
items: z.array(messageFeedbackItemSchema),
|
||||
}).superRefine((row, ctx) => {
|
||||
const messageIds = new Set<string>()
|
||||
const versions = new Set<string>()
|
||||
row.items.forEach((item, index) => {
|
||||
if (messageIds.has(item.messageId)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['items', index, 'messageId'],
|
||||
message: `duplicate message feedback id '${item.messageId}'`,
|
||||
})
|
||||
}
|
||||
messageIds.add(item.messageId)
|
||||
if (versions.has(item.version)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['items', index, 'version'],
|
||||
message: `duplicate message feedback version '${item.version}'`,
|
||||
})
|
||||
}
|
||||
versions.add(item.version)
|
||||
})
|
||||
})
|
||||
|
||||
/** Durable sidecar row inferred from {@link messageFeedbackRowSchema}. */
|
||||
export type MessageFeedbackRow = z.infer<typeof messageFeedbackRowSchema>
|
||||
|
||||
/** One lifecycle-bound sidecar record per Session id. */
|
||||
export const messageFeedbackDomainSpec = defineDomain({
|
||||
name: 'message_feedback',
|
||||
version: 0,
|
||||
tables: {
|
||||
sessions: domainTable<SessionId, MessageFeedbackRow>(messageFeedbackRowSchema),
|
||||
},
|
||||
})
|
||||
151
packages/feedback/message-feedback/src/types.ts
Normal file
151
packages/feedback/message-feedback/src/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Public request, value, and failure vocabulary for per-message feedback.
|
||||
* This module contains types only so generated Remote clients can consume it
|
||||
* without importing Host runtime code.
|
||||
* @module @deepseek-ai/dsh-message-feedback/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Opaque compare-and-set token for one exact feedback item revision. */
|
||||
export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
|
||||
|
||||
/** The human's overall judgment of one assistant message. */
|
||||
export type MessageFeedbackRating = 'positive' | 'negative'
|
||||
|
||||
/** One current feedback value and its opaque mutation token. */
|
||||
export interface MessageFeedbackItem {
|
||||
/** Stable identity of the assistant message inside the owning Session. */
|
||||
readonly messageId: MessageId
|
||||
/** Overall positive or negative judgment. */
|
||||
readonly rating: MessageFeedbackRating
|
||||
/** Optional explanation, preserved verbatim after validation. */
|
||||
readonly note?: string
|
||||
/** Equality-only token replaced by every material create or update. */
|
||||
readonly version: MessageFeedbackVersion
|
||||
/** Host-assigned creation time in Unix epoch milliseconds. */
|
||||
readonly createdAt: number
|
||||
/** Host-assigned time of the most recent material update. */
|
||||
readonly updatedAt: number
|
||||
}
|
||||
|
||||
/** Read all message feedback belonging to one persisted Session lifecycle. */
|
||||
export interface MessageFeedbackListRequest {
|
||||
/** Persisted Session whose sidecar should be read. */
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Current feedback values for one Session, in first-creation order. */
|
||||
export interface MessageFeedbackListValue {
|
||||
/** Fresh immutable item snapshots. */
|
||||
readonly items: readonly MessageFeedbackItem[]
|
||||
}
|
||||
|
||||
/** Create or replace feedback for one assistant message. */
|
||||
export interface MessageFeedbackPutRequest {
|
||||
/** Persisted Session that owns the target message. */
|
||||
readonly sessionId: SessionId
|
||||
/** Target assistant-message identity. */
|
||||
readonly messageId: MessageId
|
||||
/** Desired overall judgment. */
|
||||
readonly rating: MessageFeedbackRating
|
||||
/** Optional non-blank explanation. */
|
||||
readonly note?: string
|
||||
/** Observed item version, or `null` to require that no item exists. */
|
||||
readonly ifVersion: MessageFeedbackVersion | null
|
||||
}
|
||||
|
||||
/** Delete feedback for one message after observing its current version. */
|
||||
export interface MessageFeedbackDeleteRequest {
|
||||
/** Persisted Session that owns the sidecar. */
|
||||
readonly sessionId: SessionId
|
||||
/** Message whose feedback should be absent after this operation. */
|
||||
readonly messageId: MessageId
|
||||
/** Observed item version; ignored when the item is already absent. */
|
||||
readonly ifVersion: MessageFeedbackVersion
|
||||
}
|
||||
|
||||
/** Idempotent deletion acknowledgement. */
|
||||
export interface MessageFeedbackDeleteValue {
|
||||
/** Stable postcondition shared by the first deletion and every retry. */
|
||||
readonly absent: true
|
||||
}
|
||||
|
||||
/** No persisted Session header exists for the requested id. */
|
||||
export interface MessageFeedbackSessionNotFound {
|
||||
readonly code: 'session-not-found'
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** The id does not name a derived, append-origin assistant message. */
|
||||
export interface MessageFeedbackTargetNotFound {
|
||||
readonly code: 'target-not-found'
|
||||
readonly sessionId: SessionId
|
||||
readonly messageId: MessageId
|
||||
}
|
||||
|
||||
/** A material mutation did not match the addressed item's current version. */
|
||||
export interface MessageFeedbackVersionConflict {
|
||||
readonly code: 'version-conflict'
|
||||
readonly sessionId: SessionId
|
||||
readonly messageId: MessageId
|
||||
/** Version supplied by the caller (`null` means create-only). */
|
||||
readonly expected: MessageFeedbackVersion | null
|
||||
/** Current version, or `null` when the item does not exist. */
|
||||
readonly actual: MessageFeedbackVersion | null
|
||||
}
|
||||
|
||||
/** A supplied note contains no non-whitespace character. */
|
||||
export interface MessageFeedbackNoteBlank {
|
||||
readonly code: 'note-blank'
|
||||
}
|
||||
|
||||
/** A supplied note exceeds the configured UTF-8 byte limit. */
|
||||
export interface MessageFeedbackNoteTooLarge {
|
||||
readonly code: 'note-too-large'
|
||||
readonly maxBytes: number
|
||||
readonly actualBytes: number
|
||||
}
|
||||
|
||||
/** Failures shared by the public message-feedback operations. */
|
||||
export type MessageFeedbackFailure =
|
||||
| MessageFeedbackSessionNotFound
|
||||
| MessageFeedbackTargetNotFound
|
||||
| MessageFeedbackVersionConflict
|
||||
| MessageFeedbackNoteBlank
|
||||
| MessageFeedbackNoteTooLarge
|
||||
|
||||
/** Successful public operation result. */
|
||||
export interface MessageFeedbackSuccess<T> {
|
||||
readonly ok: true
|
||||
readonly value: T
|
||||
}
|
||||
|
||||
/** Rejected public operation result with a stable business failure. */
|
||||
export interface MessageFeedbackRejected<E extends MessageFeedbackFailure> {
|
||||
readonly ok: false
|
||||
readonly error: E
|
||||
}
|
||||
|
||||
/** Result returned by the message-feedback `list` operation. */
|
||||
export type MessageFeedbackListResult =
|
||||
| MessageFeedbackSuccess<MessageFeedbackListValue>
|
||||
| MessageFeedbackRejected<MessageFeedbackSessionNotFound>
|
||||
|
||||
/** Result returned by the message-feedback `put` operation. */
|
||||
export type MessageFeedbackPutResult =
|
||||
| MessageFeedbackSuccess<MessageFeedbackItem>
|
||||
| MessageFeedbackRejected<
|
||||
| MessageFeedbackSessionNotFound
|
||||
| MessageFeedbackTargetNotFound
|
||||
| MessageFeedbackVersionConflict
|
||||
| MessageFeedbackNoteBlank
|
||||
| MessageFeedbackNoteTooLarge
|
||||
>
|
||||
|
||||
/** Result returned by the message-feedback `delete` operation. */
|
||||
export type MessageFeedbackDeleteResult =
|
||||
| MessageFeedbackSuccess<MessageFeedbackDeleteValue>
|
||||
| MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
|
||||
Reference in New Issue
Block a user