Files
ai-code-app/etc/servers/work-server/src/services/automation-context-config-service.ts

361 lines
12 KiB
TypeScript

import { db } from '../db/client.js';
const AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
export type AutomationContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
];
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeEnabled(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
return false;
}
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
return true;
}
}
return value !== false;
}
function buildContextTitleKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const rawId = normalizeText(record.id);
const normalizedId =
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: normalizedId,
title: title || 'Context',
content,
enabled: normalizeEnabled(record.enabled),
defaultSelected: normalizeEnabled(record.defaultSelected),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationContextRecord>();
const bySemanticKey = new Map<string, AutomationContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = buildContextTitleKey(item.title);
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
}
async function ensureAutomationContextsTable() {
const hasTable = await db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE);
if (!hasTable) {
await db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
table.string('id').primary();
table.string('title').notNullable();
table.text('content').notNullable().defaultTo('');
table.boolean('enabled').notNullable().defaultTo(true);
table.boolean('default_selected').notNullable().defaultTo(false);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['title', (table) => table.string('title').notNullable().defaultTo('')],
['content', (table) => table.text('content').notNullable().defaultTo('')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['default_selected', (table) => table.boolean('default_selected').notNullable().defaultTo(false)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
createColumn(table);
});
}
}
}
function parseContextsFromLegacyValue(value: unknown) {
if (typeof value !== 'string') {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toAutomationContextRecord(row: Record<string, unknown>) {
return normalizeAutomationContext({
id: typeof row.id === 'string' ? row.id : undefined,
title: typeof row.title === 'string' ? row.title : undefined,
content: typeof row.content === 'string' ? row.content : undefined,
enabled: normalizeEnabled(row.enabled),
defaultSelected: normalizeEnabled(row.default_selected),
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
});
}
async function replaceAutomationContextsInTable(items: AutomationContextRecord[]) {
await ensureAutomationContextsTable();
const nextItems = sanitizeAutomationContexts(items);
await db.transaction(async (trx) => {
await trx(AUTOMATION_CONTEXTS_TABLE).del();
await trx(AUTOMATION_CONTEXTS_TABLE).insert(
nextItems.map((item) => ({
id: item.id,
title: item.title,
content: item.content,
enabled: item.enabled,
default_selected: item.defaultSelected,
updated_at: item.updatedAt,
})),
);
});
return nextItems;
}
async function seedAutomationContextsFromLegacySources() {
const seededItems: Partial<AutomationContextRecord>[] = [...DEFAULT_AUTOMATION_CONTEXTS];
const hasAutomationTypesTable = await db.schema.hasTable('automation_types');
if (hasAutomationTypesTable) {
const rows = await db('automation_types').select('contexts_json');
for (const row of rows) {
seededItems.push(...parseContextsFromLegacyValue((row as Record<string, unknown>).contexts_json));
}
}
return replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems));
}
function isSameAutomationContextList(left: AutomationContextRecord[], right: AutomationContextRecord[]) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const target = right[index];
return (
target &&
item.id === target.id &&
item.title === target.title &&
item.content === target.content &&
item.enabled === target.enabled &&
item.defaultSelected === target.defaultSelected &&
item.updatedAt === target.updatedAt
);
});
}
function mergeDefaultAutomationContexts(items: AutomationContextRecord[]) {
const byId = new Map(items.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_AUTOMATION_CONTEXTS) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
title: defaultItem.title,
content: existingItem.content || defaultItem.content,
});
}
return sanitizeAutomationContexts(Array.from(byId.values()));
}
async function readAutomationContextsFromTable() {
await ensureAutomationContextsTable();
const rows = await db(AUTOMATION_CONTEXTS_TABLE)
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
.orderBy('title', 'asc');
const savedItems = rows
.map((row) => toAutomationContextRecord(row as Record<string, unknown>))
.filter((item): item is AutomationContextRecord => Boolean(item));
return sanitizeAutomationContexts(savedItems);
}
export async function getAutomationContextsConfig() {
const savedContexts = await readAutomationContextsFromTable();
if (savedContexts.length === 0 || savedContexts === DEFAULT_AUTOMATION_CONTEXTS) {
return seedAutomationContextsFromLegacySources();
}
const mergedContexts = mergeDefaultAutomationContexts(savedContexts);
if (!isSameAutomationContextList(savedContexts, mergedContexts)) {
await replaceAutomationContextsInTable(mergedContexts);
}
return mergedContexts;
}
export async function upsertAutomationContextsConfig(items: unknown[]) {
const nextContexts = mergeDefaultAutomationContexts(
sanitizeAutomationContexts(Array.isArray(items) ? (items as Partial<AutomationContextRecord>[]) : []),
);
return replaceAutomationContextsInTable(nextContexts);
}
export function normalizeAutomationContextSelection(value: unknown) {
const rawValues = Array.isArray(value)
? value
: typeof value === 'string'
? value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
}
export function resolveAutomationContexts(
contexts: AutomationContextRecord[] | null | undefined,
selectedContextIds?: unknown,
) {
const normalizedContexts = sanitizeAutomationContexts(contexts);
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
return [];
}
if (requestedIds.length === 0) {
return normalizedContexts.filter((item) => item.enabled && item.defaultSelected);
}
const requestedIdSet = new Set(requestedIds);
return normalizedContexts.filter((item) => requestedIdSet.has(item.id));
}