o2o-site-AEO/ontology/src/generation/generation.service.ts
Mina Choi 01098835e9 [chore] docker-compose,ontology: 온톨로지를 이 레포로 들여 compose 한 벌로 띄운다 — 앱 Dockerfile 신설
발행이 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>
2026-09-14 17:41:03 +09:00

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;
}
}