import { Inject, Injectable } from '@nestjs/common'; import { PG } from '../db/db.module'; import { Sql } from '../db/db'; import { KeywordRepository } from '../keywords/keyword.repository'; import { LlmProvider } from '../llm/types'; import { MerchantsService } from '../merchants/merchants.service'; export interface SeoPayload { merchant: { id: string; externalId: string; name: string; siteUrl: string | null }; title: string; description: string; keywords: string[]; tags: Array<{ keyword: string; intent: string; relevance: number; aliases: string[] }>; generatedAt: string | null; } export interface AeoPayload { merchant: { id: string; externalId: string; name: string }; topics: string[]; faqs: Array<{ question: string; answer: string }>; /** JSON-LD 는 후속 단계에서 이 payload 를 그대로 매핑해 생성한다 */ structuredDataHints: { type: 'LocalBusiness'; name: string; description: string; areaServed: string | null; category: string | null; }; } @Injectable() export class ServingService { constructor( @Inject(PG) private readonly sql: Sql, private readonly merchants: MerchantsService, private readonly keywords: KeywordRepository, private readonly llm: LlmProvider, ) {} async seo(idOrExternalId: string, limit: number): Promise { const m = await this.merchants.findWithTaxonomy(idOrExternalId); const rows = await this.sql< Array<{ canonical: string; intent: string; relevance: number; aliases: string[] }> >` SELECT k.canonical, k.intent, mk.relevance, k.aliases FROM merchant_keyword mk JOIN keyword k ON k.id = mk.keyword_id WHERE mk.merchant_id = ${m.id} AND mk.status = 'active' ORDER BY mk.relevance DESC, k.usage_count ASC LIMIT ${limit}`; const kws = rows.map((r) => r.canonical); const locality = [m.region_name, m.industry_name].filter(Boolean).join(' '); return { merchant: { id: m.id, externalId: m.external_id, name: m.name, siteUrl: m.site_url }, title: locality ? `${m.name} | ${locality}` : m.name, description: buildDescription(m.name, m.description, kws), keywords: kws, tags: rows.map((r) => ({ keyword: r.canonical, intent: r.intent, relevance: Number(r.relevance), aliases: r.aliases ?? [], })), generatedAt: m.last_generated_at ? new Date(m.last_generated_at).toISOString() : null, }; } async aeo(idOrExternalId: string, limit: number): Promise { const m = await this.merchants.findWithTaxonomy(idOrExternalId); const faqs = await this.sql>` SELECT question, answer FROM qa_pair WHERE merchant_id = ${m.id} AND status = 'active' ORDER BY created_at ASC LIMIT ${limit}`; const topics = await this.sql>` SELECT k.canonical FROM merchant_keyword mk JOIN keyword k ON k.id = mk.keyword_id WHERE mk.merchant_id = ${m.id} AND mk.status = 'active' AND k.intent IN ('informational', 'local') ORDER BY mk.relevance DESC LIMIT ${limit}`; return { merchant: { id: m.id, externalId: m.external_id, name: m.name }, topics: topics.map((t) => t.canonical), faqs, structuredDataHints: { type: 'LocalBusiness', name: m.name, description: m.description, areaServed: m.region_name, category: m.industry_name, }, }; } async searchKeywords(query: string, limit: number) { const [embedding] = await this.llm.embed([query]); return this.keywords.searchByVector(embedding, 'ko-KR', limit); } /** Search Console / 유입 로그 피드백 → 저성과 키워드 강등 */ async applyPerformance( idOrExternalId: string, items: Array<{ keyword: string; impressions: number; clicks: number }>, ) { const m = await this.merchants.findWithTaxonomy(idOrExternalId); let updated = 0; for (const it of items) { const rows = await this.sql>` UPDATE merchant_keyword mk SET impressions = mk.impressions + ${it.impressions}, clicks = mk.clicks + ${it.clicks}, ctr = CASE WHEN (mk.impressions + ${it.impressions}) > 0 THEN (mk.clicks + ${it.clicks})::real / (mk.impressions + ${it.impressions}) ELSE 0 END, updated_at = now() FROM keyword k WHERE k.id = mk.keyword_id AND mk.merchant_id = ${m.id} AND (k.canonical = ${it.keyword} OR ${it.keyword} = ANY(k.aliases)) RETURNING mk.keyword_id`; updated += rows.length; } // 노출은 충분한데 클릭이 없는 키워드는 강등 → 다음 생성 사이클에서 대체 const demoted = await this.sql>` UPDATE merchant_keyword SET status = 'demoted', updated_at = now() WHERE merchant_id = ${m.id} AND status = 'active' AND impressions >= 100 AND ctr < 0.002 RETURNING keyword_id`; return { matched: updated, demoted: demoted.length }; } } function buildDescription(name: string, desc: string, keywords: string[]): string { const base = desc?.trim() || `${name} 안내`; const tail = keywords.slice(0, 3).join(', '); const full = tail ? `${base} ${tail} 정보를 확인하세요.` : base; return full.length > 155 ? `${full.slice(0, 152)}...` : full; }