발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면 `[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 — 화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다. - ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다) - ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이 musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다 - docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가. 자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다 - 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다 - 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다 검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
217 lines
7.9 KiB
TypeScript
217 lines
7.9 KiB
TypeScript
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<GenerationStats> {
|
|
const startedAt = Date.now();
|
|
const merchant = await this.merchants.findWithTaxonomy(idOrExternalId);
|
|
|
|
const runRows = await this.sql<Array<{ id: string }>>`
|
|
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<string[]> {
|
|
const rows = await this.sql<Array<{ canonical: string }>>`
|
|
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<boolean> {
|
|
const status = relevance >= 0.5 ? 'active' : 'candidate';
|
|
const rows = await this.sql<Array<{ inserted: boolean }>>`
|
|
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<number> {
|
|
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<Array<{ inserted: boolean }>>`
|
|
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;
|
|
}
|
|
}
|