import { Inject, Injectable, Logger } from '@nestjs/common'; import { PG } from '../db/db.module'; import { asJson, Sql, toVector } from '../db/db'; import { env, PROMPT_VERSION } from '../config/env'; import { DedupAction, DedupService } from '../keywords/dedup.service'; import { canonicalizeKeyword, normalizeKeyword } from '../keywords/normalize'; import { EmbeddingProvider } from '../embedding/types'; import { LlmProvider, MerchantContext } from '../llm/types'; import { MerchantsService } from '../merchants/merchants.service'; export type GenerationTrigger = 'published' | 'scheduled' | 'manual'; export interface GenerationStats { runId: string; merchantId: string; merchantName: string; provider: string; model: string; candidates: number; created: number; matchedExact: number; matchedTrigram: number; matchedVector: number; rejected: number; linked: number; qaCreated: number; durationMs: number; details: Array<{ candidate: string; action: DedupAction; matchedTo?: string; similarity?: number }>; } @Injectable() export class GenerationService { private readonly logger = new Logger(GenerationService.name); constructor( @Inject(PG) private readonly sql: Sql, private readonly merchants: MerchantsService, private readonly llm: LlmProvider, private readonly embedder: EmbeddingProvider, private readonly dedup: DedupService, ) {} async runForMerchant( idOrExternalId: string, trigger: GenerationTrigger = 'manual', targetCount = env.generation.targetKeywords, ): Promise { const startedAt = Date.now(); const merchant = await this.merchants.findWithTaxonomy(idOrExternalId); const runRows = await this.sql>` INSERT INTO generation_run (merchant_id, provider, model, prompt_version, trigger, status, input) VALUES (${merchant.id}, ${this.llm.name}, ${this.llm.model}, ${PROMPT_VERSION}, ${trigger}, 'running', ${this.sql.json(asJson({ externalId: merchant.external_id }))}) RETURNING id`; const runId = runRows[0].id; try { const existing = await this.existingKeywordsFor(merchant.id, merchant.industry_id); const ctx: MerchantContext = { externalId: merchant.external_id, name: merchant.name, description: merchant.description, industryName: merchant.industry_name, industryPath: merchant.industry_path, regionName: merchant.region_name, regionPath: merchant.region_path, profile: merchant.profile ?? {}, existingKeywords: existing, targetCount, }; const output = await this.llm.generate(ctx); // 임베딩은 한 번에 배치 호출 (후보 수만큼 왕복하지 않는다) const texts = output.keywords.map((k) => canonicalizeKeyword(k.keyword)); const embeddings = texts.length ? await this.embedder.embed(texts, 'passage') : []; const stats: GenerationStats = { runId, merchantId: merchant.id, merchantName: merchant.name, provider: this.llm.name, model: output.model, candidates: output.keywords.length, created: 0, matchedExact: 0, matchedTrigram: 0, matchedVector: 0, rejected: 0, linked: 0, qaCreated: 0, durationMs: 0, details: [], }; for (let i = 0; i < output.keywords.length; i++) { const cand = output.keywords[i]; const result = await this.dedup.resolve({ raw: cand.keyword, intent: cand.intent, embedding: embeddings[i], locale: 'ko-KR', industryId: merchant.industry_id, regionId: merchant.region_id, }); stats.details.push({ candidate: canonicalizeKeyword(cand.keyword), action: result.action, matchedTo: result.matchedTo, similarity: result.similarity, }); switch (result.action) { case 'created': stats.created++; break; case 'matched_exact': stats.matchedExact++; break; case 'matched_trigram': stats.matchedTrigram++; break; case 'matched_vector': stats.matchedVector++; break; case 'rejected_banned': stats.rejected++; break; } if (result.keywordId) { const linked = await this.linkKeyword(merchant.id, result.keywordId, cand.relevance, cand.rationale); if (linked) stats.linked++; } } stats.qaCreated = await this.upsertQaPairs(merchant.id, output.qaPairs); await this.merchants.markGenerated(merchant.id); stats.durationMs = Date.now() - startedAt; await this.sql` UPDATE generation_run SET status = 'succeeded', output = ${this.sql.json(asJson(output))}, stats = ${this.sql.json(asJson({ ...stats, details: undefined }))}, finished_at = now() WHERE id = ${runId}`; this.logger.log( `[${merchant.name}] cand=${stats.candidates} new=${stats.created} ` + `dup(exact/trg/vec)=${stats.matchedExact}/${stats.matchedTrigram}/${stats.matchedVector} ` + `rejected=${stats.rejected} qa=${stats.qaCreated} ${stats.durationMs}ms`, ); return stats; } catch (err) { const message = err instanceof Error ? err.message : String(err); await this.sql` UPDATE generation_run SET status = 'failed', error = ${message}, finished_at = now() WHERE id = ${runId}`; throw err; } } /** 프롬프트에 넣을 "이미 보유한 키워드": 자기 것 + 같은 업종에서 많이 쓰는 것 */ private async existingKeywordsFor(merchantId: string, industryId: string | null): Promise { const rows = await this.sql>` SELECT DISTINCT k.canonical FROM keyword k LEFT JOIN merchant_keyword mk ON mk.keyword_id = k.id AND mk.merchant_id = ${merchantId} WHERE mk.merchant_id IS NOT NULL OR (${industryId}::text IS NOT NULL AND k.industry_id = ${industryId} AND k.usage_count > 0) ORDER BY k.canonical LIMIT 100`; return rows.map((r) => r.canonical); } private async linkKeyword( merchantId: string, keywordId: string, relevance: number, rationale: string, ): Promise { const status = relevance >= 0.5 ? 'active' : 'candidate'; const rows = await this.sql>` INSERT INTO merchant_keyword (merchant_id, keyword_id, relevance, source, status, rationale) VALUES (${merchantId}, ${keywordId}, ${relevance}, 'llm', ${status}, ${rationale}) ON CONFLICT (merchant_id, keyword_id) DO UPDATE SET relevance = GREATEST(merchant_keyword.relevance, EXCLUDED.relevance), rationale = COALESCE(EXCLUDED.rationale, merchant_keyword.rationale), updated_at = now() RETURNING (xmax = 0) AS inserted`; if (rows[0]?.inserted) { await this.sql`UPDATE keyword SET usage_count = usage_count + 1 WHERE id = ${keywordId}`; return true; } return false; } private async upsertQaPairs( merchantId: string, pairs: Array<{ question: string; answer: string }>, ): Promise { if (pairs.length === 0) return 0; const embeddings = await this.embedder.embed(pairs.map((p) => p.question), 'passage'); let created = 0; for (let i = 0; i < pairs.length; i++) { const p = pairs[i]; const nq = normalizeKeyword(p.question); if (!nq) continue; const rows = await this.sql>` INSERT INTO qa_pair (merchant_id, question, answer, normalized_question, embedding) VALUES (${merchantId}, ${canonicalizeKeyword(p.question)}, ${p.answer.trim()}, ${nq}, ${toVector(embeddings[i])}::vector) ON CONFLICT (merchant_id, normalized_question) DO UPDATE SET answer = EXCLUDED.answer, updated_at = now() RETURNING (xmax = 0) AS inserted`; if (rows[0]?.inserted) created++; } return created; } }