875 lines
24 KiB
TypeScript
875 lines
24 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { randomUUID } from 'crypto';
|
|
import { Repository } from 'typeorm';
|
|
import { UserList } from '../list-templates/list-template.types';
|
|
import { ListsService } from '../lists/lists.service';
|
|
import { DailyDashboardSnapshotEntity } from './daily-dashboard-snapshot.entity';
|
|
import {
|
|
CreateDashboardSuggestionResponse,
|
|
DashboardImportantList,
|
|
DashboardResponse,
|
|
DashboardWeeklySuggestion,
|
|
} from './dashboard.types';
|
|
import {
|
|
WeeklyListSuggestionSnapshotEntity,
|
|
WeeklyListSuggestionSnapshotItem,
|
|
WeeklyListSuggestionSnapshotSuggestion,
|
|
} from './weekly-list-suggestion-snapshot.entity';
|
|
|
|
interface MistralCompletionResponse {
|
|
choices?: Array<{
|
|
message?: {
|
|
content?: string | null;
|
|
};
|
|
messages?: Array<{
|
|
role?: string;
|
|
content?: string | null | unknown[];
|
|
}>;
|
|
}>;
|
|
outputs?: Array<{
|
|
type?: string;
|
|
role?: string;
|
|
content?: string | null | unknown[];
|
|
}>;
|
|
output_text?: string | null;
|
|
}
|
|
|
|
interface DashboardAiResult<T> {
|
|
requestPayload: Record<string, unknown>;
|
|
responsePayload: unknown;
|
|
value: T;
|
|
}
|
|
|
|
class DashboardAiCallError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly requestPayload: Record<string, unknown>,
|
|
readonly responsePayload: unknown,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
interface SelectedListSnapshot {
|
|
listId: string;
|
|
reason: string;
|
|
source: 'ai' | 'fallback';
|
|
}
|
|
|
|
@Injectable()
|
|
export class DashboardService {
|
|
private readonly endpoint = 'https://api.mistral.ai/v1/conversations';
|
|
private readonly defaultModel = 'mistral-large-latest';
|
|
private readonly defaultTimezone = 'Europe/Budapest';
|
|
|
|
constructor(
|
|
@InjectRepository(DailyDashboardSnapshotEntity)
|
|
private readonly dailySnapshotsRepository: Repository<DailyDashboardSnapshotEntity>,
|
|
@InjectRepository(WeeklyListSuggestionSnapshotEntity)
|
|
private readonly weeklySnapshotsRepository: Repository<WeeklyListSuggestionSnapshotEntity>,
|
|
private readonly listsService: ListsService,
|
|
) {}
|
|
|
|
async getDashboard(userId: string): Promise<DashboardResponse> {
|
|
const timezone = this.dashboardTimezone();
|
|
const now = new Date();
|
|
const dateKey = this.dateKey(now, timezone);
|
|
const weekKey = this.weekKey(now, timezone);
|
|
const visibleLists = await this.listsService.listLists(userId);
|
|
const dailySnapshot = await this.getOrCreateDailySnapshot(
|
|
userId,
|
|
dateKey,
|
|
timezone,
|
|
visibleLists,
|
|
);
|
|
const weeklySnapshot = await this.getOrCreateWeeklySnapshot(
|
|
userId,
|
|
weekKey,
|
|
timezone,
|
|
visibleLists,
|
|
);
|
|
|
|
return {
|
|
timezone,
|
|
dateKey,
|
|
weekKey,
|
|
importantLists: this.toImportantLists(dailySnapshot, visibleLists),
|
|
weeklySuggestions: {
|
|
suggestions: weeklySnapshot.suggestions.map((suggestion) => ({
|
|
...suggestion,
|
|
})),
|
|
errorMessage: weeklySnapshot.errorMessage ?? undefined,
|
|
generatedAt: weeklySnapshot.createdAt?.toISOString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
async createSuggestionList(
|
|
userId: string,
|
|
suggestionId: string,
|
|
): Promise<CreateDashboardSuggestionResponse> {
|
|
const timezone = this.dashboardTimezone();
|
|
const weekKey = this.weekKey(new Date(), timezone);
|
|
const snapshot = await this.weeklySnapshotsRepository.findOne({
|
|
where: { userId, weekKey },
|
|
});
|
|
|
|
if (!snapshot) {
|
|
throw new NotFoundException('Weekly suggestions were not found.');
|
|
}
|
|
|
|
const suggestion = snapshot.suggestions.find(
|
|
(candidate) => candidate.id === suggestionId,
|
|
);
|
|
|
|
if (!suggestion) {
|
|
throw new NotFoundException('Weekly suggestion was not found.');
|
|
}
|
|
|
|
if (suggestion.createdListId) {
|
|
throw new BadRequestException('Weekly suggestion was already created.');
|
|
}
|
|
|
|
const list = await this.listsService.createList(userId, {
|
|
name: suggestion.title,
|
|
description: suggestion.description,
|
|
kind: 'custom',
|
|
});
|
|
let updatedList = list;
|
|
|
|
for (const item of suggestion.items.slice(0, 40)) {
|
|
updatedList = await this.listsService.addItem(userId, list.id, {
|
|
title: item.title,
|
|
notes: item.notes,
|
|
quantity: item.quantity,
|
|
required: item.required,
|
|
});
|
|
}
|
|
|
|
suggestion.createdListId = updatedList.id;
|
|
await this.weeklySnapshotsRepository.save(snapshot);
|
|
|
|
return {
|
|
list: updatedList,
|
|
suggestion: { ...suggestion },
|
|
};
|
|
}
|
|
|
|
private async getOrCreateDailySnapshot(
|
|
userId: string,
|
|
dateKey: string,
|
|
timezone: string,
|
|
visibleLists: UserList[],
|
|
): Promise<DailyDashboardSnapshotEntity> {
|
|
const existingSnapshot = await this.dailySnapshotsRepository.findOne({
|
|
where: { userId, dateKey },
|
|
});
|
|
|
|
if (existingSnapshot) {
|
|
return existingSnapshot;
|
|
}
|
|
|
|
try {
|
|
const result = await this.selectImportantListsWithAi(visibleLists);
|
|
return this.dailySnapshotsRepository.save(
|
|
this.dailySnapshotsRepository.create({
|
|
id: randomUUID(),
|
|
userId,
|
|
dateKey,
|
|
timezone,
|
|
selectedLists: result.value,
|
|
requestPayload: result.requestPayload,
|
|
responsePayload: result.responsePayload,
|
|
errorMessage: null,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
return this.dailySnapshotsRepository.save(
|
|
this.dailySnapshotsRepository.create({
|
|
id: randomUUID(),
|
|
userId,
|
|
dateKey,
|
|
timezone,
|
|
selectedLists: this.fallbackImportantLists(visibleLists),
|
|
requestPayload: this.errorRequestPayload(error, {
|
|
provider: 'listify',
|
|
strategy: 'deterministic-dashboard-fallback',
|
|
}),
|
|
responsePayload: this.errorResponsePayload(error),
|
|
errorMessage: this.errorMessage(error),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private async getOrCreateWeeklySnapshot(
|
|
userId: string,
|
|
weekKey: string,
|
|
timezone: string,
|
|
visibleLists: UserList[],
|
|
): Promise<WeeklyListSuggestionSnapshotEntity> {
|
|
const existingSnapshot = await this.weeklySnapshotsRepository.findOne({
|
|
where: { userId, weekKey },
|
|
});
|
|
|
|
if (existingSnapshot) {
|
|
return existingSnapshot;
|
|
}
|
|
|
|
try {
|
|
const result = await this.createWeeklySuggestionsWithAi(visibleLists);
|
|
return this.weeklySnapshotsRepository.save(
|
|
this.weeklySnapshotsRepository.create({
|
|
id: randomUUID(),
|
|
userId,
|
|
weekKey,
|
|
timezone,
|
|
suggestions: result.value,
|
|
requestPayload: result.requestPayload,
|
|
responsePayload: result.responsePayload,
|
|
errorMessage: null,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
return this.weeklySnapshotsRepository.save(
|
|
this.weeklySnapshotsRepository.create({
|
|
id: randomUUID(),
|
|
userId,
|
|
weekKey,
|
|
timezone,
|
|
suggestions: [],
|
|
requestPayload: this.errorRequestPayload(error, {
|
|
provider: 'mistral',
|
|
endpoint: this.endpoint,
|
|
errorPhase: 'weekly-suggestions',
|
|
}),
|
|
responsePayload: this.errorResponsePayload(error),
|
|
errorMessage: this.errorMessage(error),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private async selectImportantListsWithAi(
|
|
lists: UserList[],
|
|
): Promise<DashboardAiResult<SelectedListSnapshot[]>> {
|
|
if (lists.length === 0) {
|
|
return {
|
|
requestPayload: { provider: 'listify', listCount: 0 },
|
|
responsePayload: null,
|
|
value: [],
|
|
};
|
|
}
|
|
|
|
const prompt = this.createDailyPrompt(lists);
|
|
const aiResponse = await this.callMistral({
|
|
prompt,
|
|
instructions:
|
|
'Waehle fuer das Listify-Dashboard die 5 wichtigsten Listen. Antworte nur mit JSON im Format {"lists":[{"listId":"...","reason":"..."}]}. Keine Markdown-Ausgabe.',
|
|
});
|
|
const selectedLists = this.normalizeSelectedLists(
|
|
aiResponse.responsePayload,
|
|
lists,
|
|
);
|
|
|
|
return {
|
|
requestPayload: aiResponse.requestPayload,
|
|
responsePayload: aiResponse.responsePayload,
|
|
value:
|
|
selectedLists.length > 0
|
|
? selectedLists
|
|
: this.fallbackImportantLists(lists),
|
|
};
|
|
}
|
|
|
|
private async createWeeklySuggestionsWithAi(
|
|
lists: UserList[],
|
|
): Promise<DashboardAiResult<WeeklyListSuggestionSnapshotSuggestion[]>> {
|
|
const prompt = this.createWeeklyPrompt(lists);
|
|
const aiResponse = await this.callMistral({
|
|
prompt,
|
|
instructions:
|
|
'Erzeuge 5 neue Listify-Listenvorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","description":"...","items":[{"title":"...","notes":"...","quantity":1,"required":true}],"reason":"..."}]}. Keine Markdown-Ausgabe.',
|
|
});
|
|
|
|
return {
|
|
requestPayload: aiResponse.requestPayload,
|
|
responsePayload: aiResponse.responsePayload,
|
|
value: this.normalizeWeeklySuggestions(aiResponse.responsePayload),
|
|
};
|
|
}
|
|
|
|
private async callMistral(input: {
|
|
prompt: string;
|
|
instructions: string;
|
|
}): Promise<{
|
|
requestPayload: Record<string, unknown>;
|
|
responsePayload: unknown;
|
|
}> {
|
|
const apiKey = process.env.MISTRAL_API_KEY;
|
|
const model = process.env.MISTRAL_MODEL ?? this.defaultModel;
|
|
const requestPayload = this.requestMetadata(
|
|
input.prompt,
|
|
input.instructions,
|
|
);
|
|
|
|
if (!apiKey) {
|
|
throw new DashboardAiCallError(
|
|
'Mistral API key is not configured.',
|
|
requestPayload,
|
|
null,
|
|
);
|
|
}
|
|
|
|
const response = await fetch(this.endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
inputs: [{ role: 'user', content: input.prompt }],
|
|
instructions: input.instructions,
|
|
stream: false,
|
|
}),
|
|
});
|
|
const responsePayload = await this.readResponsePayload(response);
|
|
|
|
if (!response.ok) {
|
|
throw new DashboardAiCallError(
|
|
'Mistral conversation request failed.',
|
|
requestPayload,
|
|
responsePayload,
|
|
);
|
|
}
|
|
|
|
return { requestPayload, responsePayload };
|
|
}
|
|
|
|
private readResponsePayload(response: Response): Promise<unknown> {
|
|
return response.text().then((rawBody) => {
|
|
if (!rawBody) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(rawBody) as unknown;
|
|
} catch {
|
|
return { rawBody };
|
|
}
|
|
});
|
|
}
|
|
|
|
private normalizeSelectedLists(
|
|
responsePayload: unknown,
|
|
visibleLists: UserList[],
|
|
): SelectedListSnapshot[] {
|
|
const visibleListIds = new Set(visibleLists.map((list) => list.id));
|
|
const selectedLists: SelectedListSnapshot[] = [];
|
|
const seenListIds = new Set<string>();
|
|
const parsed = this.parseObject(
|
|
this.extractMistralContent(responsePayload),
|
|
);
|
|
const candidates = Array.isArray(parsed?.lists) ? parsed.lists : [];
|
|
|
|
for (const value of candidates) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
continue;
|
|
}
|
|
|
|
const candidate = value as { listId?: unknown; reason?: unknown };
|
|
const listId =
|
|
typeof candidate.listId === 'string' ? candidate.listId : '';
|
|
|
|
if (
|
|
!visibleListIds.has(listId) ||
|
|
seenListIds.has(listId) ||
|
|
selectedLists.length >= 5
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
seenListIds.add(listId);
|
|
selectedLists.push({
|
|
listId,
|
|
reason:
|
|
this.compactString(
|
|
typeof candidate.reason === 'string' ? candidate.reason : undefined,
|
|
360,
|
|
) ?? 'Diese Liste ist heute relevant.',
|
|
source: 'ai',
|
|
});
|
|
}
|
|
|
|
if (selectedLists.length < Math.min(5, visibleLists.length)) {
|
|
for (const fallback of this.fallbackImportantLists(visibleLists)) {
|
|
if (seenListIds.has(fallback.listId)) {
|
|
continue;
|
|
}
|
|
|
|
selectedLists.push(fallback);
|
|
seenListIds.add(fallback.listId);
|
|
|
|
if (selectedLists.length === 5) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return selectedLists;
|
|
}
|
|
|
|
private normalizeWeeklySuggestions(
|
|
responsePayload: unknown,
|
|
): WeeklyListSuggestionSnapshotSuggestion[] {
|
|
const parsed = this.parseObject(
|
|
this.extractMistralContent(responsePayload),
|
|
);
|
|
const candidates = Array.isArray(parsed?.suggestions)
|
|
? parsed.suggestions
|
|
: [];
|
|
const suggestions: WeeklyListSuggestionSnapshotSuggestion[] = [];
|
|
const seenTitles = new Set<string>();
|
|
|
|
for (const value of candidates) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
continue;
|
|
}
|
|
|
|
const candidate = value as {
|
|
title?: unknown;
|
|
description?: unknown;
|
|
items?: unknown;
|
|
reason?: unknown;
|
|
};
|
|
const title = this.compactString(
|
|
typeof candidate.title === 'string' ? candidate.title : undefined,
|
|
160,
|
|
);
|
|
|
|
if (!title) {
|
|
continue;
|
|
}
|
|
|
|
const titleKey = this.normalizedKey(title);
|
|
|
|
if (seenTitles.has(titleKey)) {
|
|
continue;
|
|
}
|
|
|
|
const items = this.normalizeSuggestionItems(candidate.items);
|
|
|
|
suggestions.push({
|
|
id: randomUUID(),
|
|
title,
|
|
description: this.compactString(
|
|
typeof candidate.description === 'string'
|
|
? candidate.description
|
|
: undefined,
|
|
500,
|
|
),
|
|
items,
|
|
reason:
|
|
this.compactString(
|
|
typeof candidate.reason === 'string' ? candidate.reason : undefined,
|
|
360,
|
|
) ?? 'Passt zu deinen bisherigen Listen.',
|
|
});
|
|
seenTitles.add(titleKey);
|
|
|
|
if (suggestions.length === 5) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return suggestions;
|
|
}
|
|
|
|
private normalizeSuggestionItems(
|
|
value: unknown,
|
|
): WeeklyListSuggestionSnapshotItem[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
const items: WeeklyListSuggestionSnapshotItem[] = [];
|
|
const seenTitles = new Set<string>();
|
|
|
|
for (const itemValue of value) {
|
|
if (
|
|
!itemValue ||
|
|
typeof itemValue !== 'object' ||
|
|
Array.isArray(itemValue)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const candidate = itemValue as {
|
|
title?: unknown;
|
|
notes?: unknown;
|
|
quantity?: unknown;
|
|
required?: unknown;
|
|
};
|
|
const title = this.compactString(
|
|
typeof candidate.title === 'string' ? candidate.title : undefined,
|
|
220,
|
|
);
|
|
|
|
if (!title) {
|
|
continue;
|
|
}
|
|
|
|
const titleKey = this.normalizedKey(title);
|
|
|
|
if (seenTitles.has(titleKey)) {
|
|
continue;
|
|
}
|
|
|
|
items.push({
|
|
title,
|
|
notes: this.compactString(
|
|
typeof candidate.notes === 'string' ? candidate.notes : undefined,
|
|
500,
|
|
),
|
|
quantity:
|
|
typeof candidate.quantity === 'number' &&
|
|
Number.isFinite(candidate.quantity) &&
|
|
candidate.quantity > 0
|
|
? candidate.quantity
|
|
: undefined,
|
|
required:
|
|
typeof candidate.required === 'boolean' ? candidate.required : true,
|
|
});
|
|
seenTitles.add(titleKey);
|
|
|
|
if (items.length === 12) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private fallbackImportantLists(lists: UserList[]): SelectedListSnapshot[] {
|
|
return [...lists]
|
|
.sort(
|
|
(left, right) => this.fallbackScore(right) - this.fallbackScore(left),
|
|
)
|
|
.slice(0, 5)
|
|
.map((list) => ({
|
|
listId: list.id,
|
|
reason: this.fallbackReason(list),
|
|
source: 'fallback',
|
|
}));
|
|
}
|
|
|
|
private fallbackScore(list: UserList): number {
|
|
const openItems = list.items.filter((item) => !item.checked).length;
|
|
const updatedAt = new Date(list.updatedAt).getTime();
|
|
const recencyScore = Number.isFinite(updatedAt)
|
|
? Math.max(0, 30 - (Date.now() - updatedAt) / 86_400_000)
|
|
: 0;
|
|
const reminderScore = list.reminderAt ? 120 : 0;
|
|
|
|
return reminderScore + openItems * 8 + recencyScore;
|
|
}
|
|
|
|
private fallbackReason(list: UserList): string {
|
|
const openItems = list.items.filter((item) => !item.checked).length;
|
|
|
|
if (list.reminderAt && openItems > 0) {
|
|
return 'Diese Liste hat eine Erinnerung und noch offene Eintraege.';
|
|
}
|
|
|
|
if (list.reminderAt) {
|
|
return 'Diese Liste hat eine aktive Erinnerung.';
|
|
}
|
|
|
|
if (openItems > 0) {
|
|
return `Diese Liste hat ${openItems} offene ${openItems === 1 ? 'Eintrag' : 'Eintraege'}.`;
|
|
}
|
|
|
|
return 'Diese Liste wurde zuletzt aktualisiert.';
|
|
}
|
|
|
|
private toImportantLists(
|
|
snapshot: DailyDashboardSnapshotEntity,
|
|
visibleLists: UserList[],
|
|
): DashboardImportantList[] {
|
|
const listsById = new Map(visibleLists.map((list) => [list.id, list]));
|
|
|
|
return snapshot.selectedLists
|
|
.map((selectedList) => {
|
|
const list = listsById.get(selectedList.listId);
|
|
|
|
if (!list) {
|
|
return null;
|
|
}
|
|
|
|
const checkedItems = list.items.filter((item) => item.checked).length;
|
|
const totalItems = list.items.length;
|
|
const openItems = totalItems - checkedItems;
|
|
|
|
return {
|
|
list,
|
|
reason: selectedList.reason,
|
|
source: selectedList.source,
|
|
checkedItems,
|
|
openItems,
|
|
totalItems,
|
|
progress: totalItems === 0 ? 0 : checkedItems / totalItems,
|
|
hasReminder: Boolean(list.reminderAt),
|
|
};
|
|
})
|
|
.filter((item): item is DashboardImportantList => item !== null);
|
|
}
|
|
|
|
private createDailyPrompt(lists: UserList[]): string {
|
|
return [
|
|
'Waehle die 5 wichtigsten vorhandenen Listen fuer das heutige Dashboard aus.',
|
|
'Bewerte Erinnerungen, offene Items, Aktualitaet, Kontext und praktischen Nutzen.',
|
|
'Nutze ausschliesslich die angegebenen listId-Werte.',
|
|
'',
|
|
this.formatListsForPrompt(lists),
|
|
].join('\n');
|
|
}
|
|
|
|
private createWeeklyPrompt(lists: UserList[]): string {
|
|
return [
|
|
'Erzeuge 5 neue Listenvorschlaege fuer diesen User.',
|
|
'Die Vorschlaege sollen vorhandene Listen sinnvoll ergaenzen und nicht einfach duplizieren.',
|
|
'Jeder Vorschlag braucht Titel, kurze Beschreibung, 3 bis 8 Beispiel-Items und eine kurze Begruendung.',
|
|
'',
|
|
this.formatListsForPrompt(lists),
|
|
].join('\n');
|
|
}
|
|
|
|
private formatListsForPrompt(lists: UserList[]): string {
|
|
if (lists.length === 0) {
|
|
return 'Vorhandene sichtbare Listen: keine';
|
|
}
|
|
|
|
const lines = ['Vorhandene sichtbare Listen:'];
|
|
|
|
for (const list of lists.slice(0, 80)) {
|
|
const checkedItems = list.items.filter((item) => item.checked).length;
|
|
const openItems = list.items.length - checkedItems;
|
|
|
|
lines.push(
|
|
[
|
|
`- listId: ${list.id}`,
|
|
`Name: ${this.compactString(list.name, 160)}`,
|
|
`Typ: ${list.kind}`,
|
|
`Beschreibung: ${this.compactString(list.description, 240) ?? 'keine'}`,
|
|
`Items: ${list.items.length}`,
|
|
`Offen: ${openItems}`,
|
|
`Erledigt: ${checkedItems}`,
|
|
`Erinnerung: ${list.reminderAt ?? 'keine'}`,
|
|
`Aktualisiert: ${list.updatedAt}`,
|
|
`Beispiel-Items: ${
|
|
list.items
|
|
.slice(0, 8)
|
|
.map((item) => item.title)
|
|
.join(', ') || 'keine'
|
|
}`,
|
|
].join(' | '),
|
|
);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
private requestMetadata(
|
|
prompt: string,
|
|
instructions?: string,
|
|
): Record<string, unknown> {
|
|
return {
|
|
provider: 'mistral',
|
|
endpoint: this.endpoint,
|
|
model: process.env.MISTRAL_MODEL ?? this.defaultModel,
|
|
prompt,
|
|
instructions,
|
|
};
|
|
}
|
|
|
|
private extractMistralContent(responsePayload: unknown): string | null {
|
|
if (!responsePayload || typeof responsePayload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const response = responsePayload as MistralCompletionResponse;
|
|
const directContent = response.choices?.[0]?.message?.content?.trim();
|
|
|
|
if (directContent) {
|
|
return directContent;
|
|
}
|
|
|
|
for (const choice of response.choices ?? []) {
|
|
const assistantMessages = (choice.messages ?? [])
|
|
.filter((message) => message.role === 'assistant')
|
|
.reverse();
|
|
|
|
for (const message of assistantMessages) {
|
|
const content = this.contentToText(message.content).trim();
|
|
|
|
if (content) {
|
|
return content;
|
|
}
|
|
}
|
|
}
|
|
|
|
const outputText = response.output_text?.trim();
|
|
|
|
if (outputText) {
|
|
return outputText;
|
|
}
|
|
|
|
for (const output of [...(response.outputs ?? [])].reverse()) {
|
|
const content = this.contentToText(output.content).trim();
|
|
|
|
if (
|
|
content &&
|
|
(output.role === 'assistant' || output.type?.includes('message'))
|
|
) {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private contentToText(value: unknown): string {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
|
|
if (!Array.isArray(value)) {
|
|
return '';
|
|
}
|
|
|
|
return value
|
|
.map((part) => {
|
|
if (typeof part === 'string') {
|
|
return part;
|
|
}
|
|
|
|
if (!part || typeof part !== 'object' || Array.isArray(part)) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = part as { text?: unknown };
|
|
return typeof candidate.text === 'string' ? candidate.text : null;
|
|
})
|
|
.filter((part): part is string => part !== null)
|
|
.join('\n');
|
|
}
|
|
|
|
private parseObject(content: string | null): Record<string, unknown> | null {
|
|
if (!content) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(content) as unknown;
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
? (parsed as Record<string, unknown>)
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private dateKey(date: Date, timezone: string): string {
|
|
const parts = this.localDateParts(date, timezone);
|
|
return `${parts.year}-${this.pad2(parts.month)}-${this.pad2(parts.day)}`;
|
|
}
|
|
|
|
private weekKey(date: Date, timezone: string): string {
|
|
const parts = this.localDateParts(date, timezone);
|
|
const localDate = new Date(
|
|
Date.UTC(parts.year, parts.month - 1, parts.day),
|
|
);
|
|
const day = localDate.getUTCDay() || 7;
|
|
localDate.setUTCDate(localDate.getUTCDate() + 4 - day);
|
|
const weekYear = localDate.getUTCFullYear();
|
|
const yearStart = new Date(Date.UTC(weekYear, 0, 1));
|
|
const week = Math.ceil(
|
|
((localDate.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7,
|
|
);
|
|
|
|
return `${weekYear}-W${this.pad2(week)}`;
|
|
}
|
|
|
|
private localDateParts(
|
|
date: Date,
|
|
timezone: string,
|
|
): { year: number; month: number; day: number } {
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: timezone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).formatToParts(date);
|
|
const partValue = (type: string) =>
|
|
Number(parts.find((part) => part.type === type)?.value);
|
|
|
|
return {
|
|
year: partValue('year'),
|
|
month: partValue('month'),
|
|
day: partValue('day'),
|
|
};
|
|
}
|
|
|
|
private dashboardTimezone(): string {
|
|
return process.env.DASHBOARD_TIMEZONE?.trim() || this.defaultTimezone;
|
|
}
|
|
|
|
private pad2(value: number): string {
|
|
return String(value).padStart(2, '0');
|
|
}
|
|
|
|
private compactString(
|
|
value: string | undefined,
|
|
maxLength: number,
|
|
): string | undefined {
|
|
const compacted = value?.replace(/\s+/g, ' ').trim();
|
|
|
|
if (!compacted) {
|
|
return undefined;
|
|
}
|
|
|
|
return compacted.length <= maxLength
|
|
? compacted
|
|
: `${compacted.slice(0, maxLength - 3)}...`;
|
|
}
|
|
|
|
private normalizedKey(value: string): string {
|
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
}
|
|
|
|
private errorMessage(error: unknown): string {
|
|
return error instanceof Error
|
|
? error.message
|
|
: 'Dashboard generation failed.';
|
|
}
|
|
|
|
private errorRequestPayload(
|
|
error: unknown,
|
|
fallback: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
return error instanceof DashboardAiCallError
|
|
? error.requestPayload
|
|
: fallback;
|
|
}
|
|
|
|
private errorResponsePayload(error: unknown): unknown {
|
|
return error instanceof DashboardAiCallError ? error.responsePayload : null;
|
|
}
|
|
}
|