Skip to content
25 changes: 25 additions & 0 deletions apps/core/src/common/errors/app-error-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,31 @@ export enum AppErrorCode {
AI_TASK_CANNOT_RETRY = 'AI_TASK_CANNOT_RETRY',
AI_TRANSLATION_NOT_FOUND = 'AI_TRANSLATION_NOT_FOUND',

// ai-embeddings
AI_EMBEDDING_MODEL_NOT_CONFIGURED = 'AI_EMBEDDING_MODEL_NOT_CONFIGURED',
AI_EMBEDDING_BATCH_FAILED = 'AI_EMBEDDING_BATCH_FAILED',

// ai-persona
AI_PERSONA_NOT_FOUND = 'AI_PERSONA_NOT_FOUND',
AI_PERSONA_PROFILE_NOT_FOUND = 'AI_PERSONA_PROFILE_NOT_FOUND',
AI_PERSONA_NOT_DISTILLABLE = 'AI_PERSONA_NOT_DISTILLABLE',
AI_PERSONA_REFRESH_IN_PROGRESS = 'AI_PERSONA_REFRESH_IN_PROGRESS',
AI_PERSONA_DISTILL_MODEL_NOT_CONFIGURED = 'AI_PERSONA_DISTILL_MODEL_NOT_CONFIGURED',

// ai-memory
AI_MEMORY_NOT_FOUND = 'AI_MEMORY_NOT_FOUND',
AI_MEMORY_INVALID_SCOPE = 'AI_MEMORY_INVALID_SCOPE',
AI_MEMORY_INVALID_TYPE = 'AI_MEMORY_INVALID_TYPE',

// ai-echo
AI_ECHO_NOT_FOUND = 'AI_ECHO_NOT_FOUND',
AI_ECHO_SUBJECT_NOT_FOUND = 'AI_ECHO_SUBJECT_NOT_FOUND',
AI_ECHO_SCENARIO_NOT_REGISTERED = 'AI_ECHO_SCENARIO_NOT_REGISTERED',
AI_ECHO_GENERATION_FAILED = 'AI_ECHO_GENERATION_FAILED',
AI_ECHO_REGENERATE_IN_PROGRESS = 'AI_ECHO_REGENERATE_IN_PROGRESS',
AI_ECHO_MODEL_NOT_CONFIGURED = 'AI_ECHO_MODEL_NOT_CONFIGURED',
AI_ECHO_DAILY_QUOTA_EXCEEDED = 'AI_ECHO_DAILY_QUOTA_EXCEEDED',

// auth
AUTH_DEVICE_FLOW_PENDING = 'AUTH_DEVICE_FLOW_PENDING',
AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS',
Expand Down
105 changes: 105 additions & 0 deletions apps/core/src/common/errors/app-error-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,111 @@ export const APP_ERROR_DEFINITIONS = {
message: 'Translation not found',
},

// ai-embeddings
[AppErrorCode.AI_EMBEDDING_MODEL_NOT_CONFIGURED]: {
status: 400,
message: 'AI embedding model is not configured',
},
[AppErrorCode.AI_EMBEDDING_BATCH_FAILED]: {
status: 502,
message: (p) => p?.message ?? 'AI embedding batch failed',
},

// ai-persona
[AppErrorCode.AI_PERSONA_NOT_FOUND]: {
status: 404,
message: (p) =>
p?.key ? `Persona "${p.key}" not found` : 'Persona not found',
details: (p) => (p?.key ? { key: p.key } : undefined),
},
[AppErrorCode.AI_PERSONA_PROFILE_NOT_FOUND]: {
status: 404,
message: (p) =>
p?.key
? `Persona profile "${p.key}" not found`
: 'Persona profile not found',
details: (p) => (p?.key ? { key: p.key } : undefined),
},
[AppErrorCode.AI_PERSONA_NOT_DISTILLABLE]: {
status: 400,
message: (p) =>
p?.key
? `Persona "${p.key}" cannot be distilled`
: 'Persona cannot be distilled',
details: (p) => (p?.key ? { key: p.key } : undefined),
},
[AppErrorCode.AI_PERSONA_REFRESH_IN_PROGRESS]: {
status: 409,
message: 'Persona refresh is already in progress',
details: (p) => (p?.key ? { key: p.key } : undefined),
},
[AppErrorCode.AI_PERSONA_DISTILL_MODEL_NOT_CONFIGURED]: {
status: 400,
message: 'AI persona distill model is not configured',
},

// ai-memory
[AppErrorCode.AI_MEMORY_NOT_FOUND]: {
status: 404,
message: 'Memory not found',
details: (p) => (p?.id ? { id: p.id } : undefined),
},
[AppErrorCode.AI_MEMORY_INVALID_SCOPE]: {
status: 400,
message: (p) =>
p?.scope ? `Invalid memory scope: ${p.scope}` : 'Invalid memory scope',
details: (p) => (p?.scope ? { scope: p.scope } : undefined),
},
[AppErrorCode.AI_MEMORY_INVALID_TYPE]: {
status: 400,
message: (p) =>
p?.type ? `Invalid memory type: ${p.type}` : 'Invalid memory type',
details: (p) => (p?.type ? { type: p.type } : undefined),
},

// ai-echo
[AppErrorCode.AI_ECHO_NOT_FOUND]: {
status: 404,
message: 'Echo not found',
details: (p) => (p?.id ? { id: p.id } : undefined),
},
[AppErrorCode.AI_ECHO_SUBJECT_NOT_FOUND]: {
status: 404,
message: 'Echo subject not found',
details: (p) =>
p?.subjectId
? { subjectType: p.subjectType, subjectId: p.subjectId }
: undefined,
},
[AppErrorCode.AI_ECHO_SCENARIO_NOT_REGISTERED]: {
status: 400,
message: (p) =>
p?.scenarioKey
? `Echo scenario "${p.scenarioKey}" is not registered`
: 'Echo scenario is not registered',
details: (p) =>
p?.scenarioKey ? { scenarioKey: p.scenarioKey } : undefined,
},
[AppErrorCode.AI_ECHO_GENERATION_FAILED]: {
status: 500,
message: (p) => p?.message ?? 'Echo generation failed',
},
[AppErrorCode.AI_ECHO_REGENERATE_IN_PROGRESS]: {
status: 409,
message: 'Echo regeneration already in progress',
details: (p) => (p?.echoId ? { echoId: p.echoId } : undefined),
},
[AppErrorCode.AI_ECHO_MODEL_NOT_CONFIGURED]: {
status: 400,
message: 'AI echo model is not configured',
},
[AppErrorCode.AI_ECHO_DAILY_QUOTA_EXCEEDED]: {
status: 429,
message: 'Echo daily quota exceeded',
details: (p) =>
p?.quota !== undefined ? { used: p?.used, quota: p?.quota } : undefined,
},

// auth
[AppErrorCode.AUTH_DEVICE_FLOW_PENDING]: {
status: 202,
Expand Down
31 changes: 31 additions & 0 deletions apps/core/src/common/errors/app-error-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,37 @@ export type AppErrorPayloadMap = {
[AppErrorCode.AI_TASK_CANNOT_RETRY]: { reason?: string } | undefined
[AppErrorCode.AI_TRANSLATION_NOT_FOUND]: undefined

// ai-embeddings
[AppErrorCode.AI_EMBEDDING_MODEL_NOT_CONFIGURED]: undefined
[AppErrorCode.AI_EMBEDDING_BATCH_FAILED]: OptMessage

// ai-persona
[AppErrorCode.AI_PERSONA_NOT_FOUND]: { key?: string } | undefined
[AppErrorCode.AI_PERSONA_PROFILE_NOT_FOUND]: { key?: string } | undefined
[AppErrorCode.AI_PERSONA_NOT_DISTILLABLE]: { key?: string } | undefined
[AppErrorCode.AI_PERSONA_REFRESH_IN_PROGRESS]: { key?: string } | undefined
[AppErrorCode.AI_PERSONA_DISTILL_MODEL_NOT_CONFIGURED]: undefined

// ai-memory
[AppErrorCode.AI_MEMORY_NOT_FOUND]: WithId
[AppErrorCode.AI_MEMORY_INVALID_SCOPE]: { scope?: string } | undefined
[AppErrorCode.AI_MEMORY_INVALID_TYPE]: { type?: string } | undefined

// ai-echo
[AppErrorCode.AI_ECHO_NOT_FOUND]: WithId
[AppErrorCode.AI_ECHO_SUBJECT_NOT_FOUND]:
| { subjectType?: string; subjectId?: string }
| undefined
[AppErrorCode.AI_ECHO_SCENARIO_NOT_REGISTERED]:
| { scenarioKey?: string }
| undefined
[AppErrorCode.AI_ECHO_GENERATION_FAILED]: OptMessage
[AppErrorCode.AI_ECHO_REGENERATE_IN_PROGRESS]: { echoId?: string } | undefined
[AppErrorCode.AI_ECHO_MODEL_NOT_CONFIGURED]: undefined
[AppErrorCode.AI_ECHO_DAILY_QUOTA_EXCEEDED]:
| { used?: number; quota?: number }
| undefined

// auth
[AppErrorCode.AUTH_DEVICE_FLOW_PENDING]: undefined
[AppErrorCode.AUTH_INVALID_CREDENTIALS]: undefined
Expand Down
3 changes: 3 additions & 0 deletions apps/core/src/constants/business-event.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export enum BusinessEvents {
RECENTLY_CREATE = 'RECENTLY_CREATE',
RECENTLY_UPDATE = 'RECENTLY_UPDATE',
RECENTLY_DELETE = 'RECENTLY_DELETE',
RECENTLY_ECHO_LANDED = 'RECENTLY_ECHO_LANDED',

PERSONA_PROFILE_REFRESHED = 'PERSONA_PROFILE_REFRESHED',

AGGREGATE_UPDATE = 'AGGREGATE_UPDATE',

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { AppMigration } from './types'

export const migration: AppMigration = {
id: '20260524-ai-corpus-initial-backfill',
description:
'Mark initial corpus_embeddings backfill window; actual backfill runs via POST /ai-embeddings/backfill once the embedding model is configured.',
async up({ logger }) {
logger.log(
'Initial AI corpus backfill marker recorded. Run POST /ai-embeddings/backfill after configuring an embedding model to populate corpus_embeddings.',
)
},
}
6 changes: 5 additions & 1 deletion apps/core/src/database/app-migrations/registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { migration as recentlyDropEnrichmentColumns } from './20260515-recently-drop-enrichment-columns'
import { migration as aiCorpusInitialBackfill } from './20260524-ai-corpus-initial-backfill'
import type { AppMigration } from './types'

/**
Expand All @@ -9,4 +10,7 @@ import type { AppMigration } from './types'
* Migrations removed from this list never re-run; the ledger row of a
* previously applied one is left in place and simply goes unreferenced.
*/
export const migrations: AppMigration[] = [recentlyDropEnrichmentColumns]
export const migrations: AppMigration[] = [
recentlyDropEnrichmentColumns,
aiCorpusInitialBackfill,
]
76 changes: 76 additions & 0 deletions apps/core/src/database/migrations/0015_ai_echo_system.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
-- migration-lint:allow=no-bare-create-index reason=ai_echoes, ai_memories, corpus_embeddings, persona_profiles are brand-new tables, empty at deploy time
CREATE EXTENSION IF NOT EXISTS vector;--> statement-breakpoint
CREATE TABLE "ai_echoes" (
"id" text PRIMARY KEY NOT NULL,
"scenario_key" text NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"persona_key" text NOT NULL,
"content" text,
"status" text NOT NULL,
"model" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"generated_at" timestamp with time zone,
"edited_at" timestamp with time zone,
"edited_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "ai_memories" (
"id" text PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"type" text NOT NULL,
"content" text NOT NULL,
"confidence" real DEFAULT 1 NOT NULL,
"salience" real DEFAULT 1 NOT NULL,
"source" jsonb DEFAULT '{}'::jsonb NOT NULL,
"embedding" vector,
"embedding_model" text,
"dim" integer,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone,
"supersedes_id" text,
"status" text DEFAULT 'active' NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "corpus_embeddings" (
"id" text PRIMARY KEY NOT NULL,
"source_type" text NOT NULL,
"source_id" text NOT NULL,
"chunk_index" integer NOT NULL,
"content" text NOT NULL,
"content_hash" text NOT NULL,
"embedding" vector NOT NULL,
"embedding_model" text NOT NULL,
"dim" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "persona_profiles" (
"id" text PRIMARY KEY NOT NULL,
"persona_key" text NOT NULL,
"profile" text NOT NULL,
"profile_summary" text,
"corpus_version" integer NOT NULL,
"distill_model" text NOT NULL,
"refreshed_at" timestamp with time zone NOT NULL,
"auto_next_at" timestamp with time zone,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
CONSTRAINT "persona_profiles_persona_key_unique" UNIQUE("persona_key")
);
--> statement-breakpoint
ALTER TABLE "ai_memories" ADD CONSTRAINT "ai_memories_supersedes_id_ai_memories_id_fk" FOREIGN KEY ("supersedes_id") REFERENCES "public"."ai_memories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_echoes_subject_idx" ON "ai_echoes" USING btree ("scenario_key","subject_type","subject_id");--> statement-breakpoint
CREATE INDEX "ai_echoes_status_idx" ON "ai_echoes" USING btree ("scenario_key","status");--> statement-breakpoint
CREATE INDEX "ai_echoes_persona_subject_idx" ON "ai_echoes" USING btree ("subject_type","subject_id","persona_key");--> statement-breakpoint
CREATE INDEX "ai_memories_scope_status_idx" ON "ai_memories" USING btree ("scope","status");--> statement-breakpoint
CREATE INDEX "ai_memories_active_idx" ON "ai_memories" USING btree ("status") WHERE "ai_memories"."status" = 'active';--> statement-breakpoint
CREATE UNIQUE INDEX "corpus_embeddings_source_chunk_model_uniq" ON "corpus_embeddings" USING btree ("source_type","source_id","chunk_index","embedding_model");--> statement-breakpoint
CREATE INDEX "corpus_embeddings_source_idx" ON "corpus_embeddings" USING btree ("source_type","source_id");
Loading
Loading