Skip to content
Merged

dev #143

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion aicontext/modules/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
## Features

### Busca unificada com cache de índice e ranking
A busca unificada em `src/core/utils/search-engine.ts` mantém em memória os dados ativos carregados dos providers, reaproveita índices Fuse por escopo de busca e reutiliza o ranking calculado para a mesma query. Para maximizar a performance com milhares de itens (ex: monstros), a busca ignora campos longos como `description` e `source`, e utiliza um `threshold` restrito de `0.15` para descartar matches distantes rapidamente. Além disso, a paginação é aplicada de forma "lazy" (antes do mapping) e o plugin de sugestões do TipTap possui um `debounce` de `150ms` para evitar sobrecarga da thread principal durante a digitação. `invalidateSearchCache()` limpa todos os caches para reconstrução.
A busca unificada mantém em memória os dados ativos carregados dos providers, reaproveita índices Fuse por escopo de busca e reutiliza o ranking calculado para a mesma query. Para maximizar a performance com milhares de itens (ex: monstros), a busca ignora campos longos como `description` e `source`, utiliza `threshold` restrito de `0.15` para descartar matches distantes rapidamente e aplica paginação de forma "lazy" antes do mapping.

`src/core/utils/search-engine.ts` preserva a API compartilhada por rotas, hooks e serviços server-side. A lógica pura fica em `src/core/utils/search-core.ts`, permitindo que o frontend execute a busca de menções em `search.worker.ts` via `search-worker-client.ts`. O worker possui cache próprio por provider, filtros, índices e rankings, é aquecido por `useWarmSearchCache()` após um atraso curto no dashboard e é invalidado junto com `invalidateSearchCache()` no browser. Para menções, o worker usa providers já carregados e emite resultados parciais conforme endpoints ainda pendentes terminam, sem esperar o cache completo. Quando `Worker` não está disponível ou falha, a fachada client usa a busca principal como fallback.

### Geração de imagens com Gemini salva no bucket
`POST /api/core/ai/image` executa a geração de imagem no servidor com um modelo Gemini compatível, extrai o primeiro `inlineData` retornado, salva o arquivo no bucket S3/MinIO do projeto e responde com `url`, `key` e `mimeType`. O endpoint aceita tanto prompt livre quanto o JSON completo de um formulário, transforma esse payload em um prompt especializado em estética oficial de Dungeons & Dragons com preferência por composição 1:1 e bloqueia uploads gerados acima de 5MB antes de persistir no bucket.
3 changes: 2 additions & 1 deletion aicontext/modules/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ This module handles the D&D Reference Rules system, allowing administrators to m
## Key Features

- **Reference Management**: CRUD operations for Rule entities.
- **Rich Text Editor**: Custom editor using Tiptap with image upload to S3, mention support, and table insertion. Mentions are enhanced with temporary badge styling (`decorationClass`). The system includes custom boundary detection (`findSuggestionMatch`) that keeps the temporary badge/query limited to the active mention word, including only the first existing word when `@` is inserted directly before text, and a smart escape mechanism using `ArrowRight` (injecting `\u200B`) to allow exiting the mention context seamlessly.
- **Rich Text Editor**: Custom editor using Tiptap with image upload to S3, mention support, and table insertion. Mentions are enhanced with temporary badge styling (`decorationClass`). The system includes custom boundary detection (`findSuggestionMatch`) that keeps the temporary badge/query limited to the active mention word, including only the first existing word when `@` is inserted directly before text, and a smart escape mechanism using `ArrowRight` (injecting `\u200B`) to allow exiting the mention context seamlessly. Mention suggestions use the unified search worker on the client so Fuse indexing/search does not block typing in the editor UI, and results are updated progressively as providers finish loading.
- **Audit Logging**: Full traceability of changes via `AuditLogExtended`.
- **Dashboard Integration**: Real-time stats on existing rules.
- **Entity Preview (MentionContent)**: Renders rich HTML content including styled tables, images, dice values, and mention badges.
- **Mention Preview Safety**: Monster previews normalize incomplete API payloads before rendering so missing nested fields such as `attributes.wisdom`, skills, senses, or action arrays do not crash the client while hovering mention results.

## Data Models

Expand Down
12 changes: 11 additions & 1 deletion src/core/hooks/useWarmSearchCache.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { useEffect } from "react"
import { warmSearchCache } from "@/core/utils/search-engine"
import { warmSearchWorkerCache } from "@/core/utils/search-worker-client"

const WARM_SEARCH_CACHE_DELAY_MS = 1500

/**
* Pre-populates the unified search cache on mount so the first `@` mention
* lookup is instant instead of waiting for the initial network fetch.
*/
export function useWarmSearchCache(): void {
useEffect(() => {
warmSearchCache()
const timer = window.setTimeout(() => {
warmSearchCache()
warmSearchWorkerCache()
}, WARM_SEARCH_CACHE_DELAY_MS)

return () => {
window.clearTimeout(timer)
}
}, [])
}
179 changes: 179 additions & 0 deletions src/core/utils/search-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import Fuse, { type FuseResult } from "fuse.js"
import type { EntityType } from "@/lib/config/colors"

export interface UnifiedEntity {
id: string
_id?: string
name: string
originalName?: string
label?: string
type: "Regra" | "Magia" | "Habilidade" | "Talento" | "Classe" | "Subclasse" | "Origem" | "Raça" | "Item" | "Monstro"
description?: string
source?: string
status: "active" | "inactive"
metadata?: UnifiedEntityMetadata
school?: string
circle?: number
saveAttribute?: string
component?: string[]
baseDice?: unknown
extraDicePerLevel?: unknown
rarity?: string
itemType?: string
price?: string
damageDice?: unknown
damageType?: string
ac?: number
acType?: string
armorType?: string
acBonus?: number
attributeUsed?: string
image?: string
isMagic?: boolean
traits?: unknown[]
properties?: unknown[]
additionalDamage?: unknown[]
mastery?: string
score?: number
}

export interface UnifiedEntityMetadata extends Record<string, unknown> {
parentClassId?: string
parentClassName?: string
subclassId?: string
subclassName?: string
subclassColor?: string
}

export interface UnifiedSearchOptions {
specificEntityType?: EntityType
specificEntityTypes?: EntityType[]
itemTypes?: string[]
circles?: number[]
parentClassId?: string | null
}

export type SearchableEntity = { name?: string; originalName?: string; label?: string; source?: string; description?: string }
type SearchResultItem = SearchableEntity & {
id?: { toString: () => string } | string
_id?: { toString: () => string } | string
toObject?: () => Record<string, unknown>
}

export const FUSE_OPTIONS = {
keys: [
{ name: "name", weight: 10 },
{ name: "originalName", weight: 8 },
{ name: "label", weight: 10 },
],
threshold: 0.15,
includeScore: true,
shouldSort: true,
minMatchCharLength: 2
}

export type FuzzyCacheEntry<T extends SearchableEntity> = {
fuse: Fuse<T>
rankedResultsByQuery: Map<string, FuseResult<T>[]>
}

export function buildFilterCacheKey(options?: UnifiedSearchOptions): string | null {
if (!options) return null

const entityTypes = options.specificEntityTypes?.length
? [...options.specificEntityTypes].sort()
: options.specificEntityType
? [options.specificEntityType]
: []

const itemTypes = options.itemTypes?.length ? [...options.itemTypes].sort() : []
const circles = options.circles?.length ? [...options.circles].sort((left, right) => left - right) : []
const parentClassId = options.parentClassId ?? null

if (entityTypes.length === 0 && itemTypes.length === 0 && circles.length === 0 && parentClassId === null) {
return null
}

return JSON.stringify({
entityTypes,
itemTypes,
circles,
parentClassId,
})
}

export function createFuzzyCacheEntry<T extends SearchableEntity>(items: T[]): FuzzyCacheEntry<T> {
return {
fuse: new Fuse(items, FUSE_OPTIONS),
rankedResultsByQuery: new Map(),
}
}

export function filterEntitiesByOptions(items: UnifiedEntity[], options?: UnifiedSearchOptions): UnifiedEntity[] {
if (!options) return items

const entityTypes = options.specificEntityTypes?.length
? options.specificEntityTypes
: options.specificEntityType
? [options.specificEntityType]
: null

return items.filter((entity) => {
if (entityTypes && !entityTypes.includes(entity.type as EntityType)) return false
if (options.itemTypes?.length && entity.type === "Item" && !options.itemTypes.includes(entity.itemType ?? "")) return false
if (options.circles?.length && entity.type === "Magia" && !options.circles.includes(entity.circle ?? -1)) return false
if (options.parentClassId && entity.type === "Subclasse" && entity.metadata?.parentClassId !== options.parentClassId) return false
return true
})
}

function getRankedFuzzyResults<T extends SearchableEntity>(
cacheEntry: FuzzyCacheEntry<T>,
query: string
): FuseResult<T>[] {
const cachedResults = cacheEntry.rankedResultsByQuery.get(query)
if (cachedResults) return cachedResults

const fuseResults = cacheEntry.fuse.search(query)
cacheEntry.rankedResultsByQuery.set(query, fuseResults)
return fuseResults
}

export function applyFuzzySearchWithCache<T extends SearchableEntity>(
items: T[],
query: string,
cacheEntry: FuzzyCacheEntry<T>,
limit?: number,
offset = 0
): T[] {
if (!query.trim()) {
return items.slice(offset, limit ? offset + limit : undefined)
}

const rawResults = getRankedFuzzyResults(cacheEntry, query)
const paginatedRawResults = limit
? rawResults.slice(offset, offset + limit)
: rawResults.slice(offset)

return paginatedRawResults.map((result) => {
const item = result.item as SearchResultItem
const baseItem = (typeof item.toObject === "function" ? item.toObject() : { ...result.item }) as Record<string, unknown> & SearchResultItem
const id = baseItem._id?.toString() || baseItem.id?.toString()

return {
...baseItem,
id,
_id: id,
score: result.score
} as unknown as T
})
}

export function applyFuzzySearch<T extends SearchableEntity>(
items: T[],
query: string,
limit?: number,
offset = 0
): T[] {
return applyFuzzySearchWithCache(items, query, createFuzzyCacheEntry(items), limit, offset)
}
Loading
Loading