|
|
@@ -0,0 +1,345 @@
|
|
|
+import { randomUUID } from 'node:crypto';
|
|
|
+import { ApiError } from '../../http/api-error.js';
|
|
|
+import type { FmodeAiClient } from '../ai-gateway/client.js';
|
|
|
+import type {
|
|
|
+ ListingAiRepository,
|
|
|
+ ListingCatalogSummary,
|
|
|
+ ListingProductFilter,
|
|
|
+ ListingScoreJob,
|
|
|
+ ListingScoreJobItem,
|
|
|
+ ListingScoreResult,
|
|
|
+ ListingScoreScope,
|
|
|
+ ListingSourceSnapshot,
|
|
|
+ ListingVersion,
|
|
|
+} from './domain.js';
|
|
|
+import { canonicalHash, LISTING_RUBRIC_VERSION, listingCoverage, scoreListing } from './scoring/rule-engine.js';
|
|
|
+import {
|
|
|
+ composeListingAiScore,
|
|
|
+ LISTING_AI_PROMPT_VERSION,
|
|
|
+ LISTING_AI_RUBRIC_VERSION,
|
|
|
+ listingAiRubricPrompt,
|
|
|
+ parseListingAiScoreOutput,
|
|
|
+ type ListingAiScoreOutput,
|
|
|
+} from './scoring/ai-rubric.js';
|
|
|
+
|
|
|
+export interface ListingAiScoringProvider {
|
|
|
+ readonly configured: boolean;
|
|
|
+ readonly model: string;
|
|
|
+ score(source: ListingSourceSnapshot, baseline: ListingScoreResult): Promise<ListingAiScoreOutput>;
|
|
|
+}
|
|
|
+
|
|
|
+export class FmodeListingAiScoringProvider implements ListingAiScoringProvider {
|
|
|
+ readonly model: string;
|
|
|
+ constructor(private readonly client: FmodeAiClient, model?: string) { this.model = model?.trim() || client.config.defaultModel; }
|
|
|
+ get configured(): boolean { return this.client.configured; }
|
|
|
+
|
|
|
+ async score(source: ListingSourceSnapshot, baseline: ListingScoreResult): Promise<ListingAiScoreOutput> {
|
|
|
+ const readableFeatures = source.features
|
|
|
+ .filter((item) => item.value.trim() && !/^[01]$/.test(item.value.trim()))
|
|
|
+ .slice(0, 40);
|
|
|
+ const payload = {
|
|
|
+ productId: source.productId,
|
|
|
+ title: source.title,
|
|
|
+ brand: source.brand.name,
|
|
|
+ categoryIds: source.categoryIds,
|
|
|
+ features: readableFeatures,
|
|
|
+ attributes: source.attributes.slice(0, 60),
|
|
|
+ images: source.images.slice(0, 20).map((item) => ({ order: item.order, isPrimary: item.isPrimary, url: item.url })),
|
|
|
+ skus: source.skus.slice(0, 15).map((item) => ({ skuId: item.skuId, name: item.name, attributes: item.attributes })),
|
|
|
+ dimensions: source.dimensions,
|
|
|
+ afterService: source.afterService,
|
|
|
+ descriptionText: `${source.descriptions.desktopHtml ?? ''} ${source.descriptions.mobileHtml ?? ''}`
|
|
|
+ .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 5_000),
|
|
|
+ structuralBaseline: baseline.dimensions.map((dimension) => ({
|
|
|
+ dimension: dimension.dimension,
|
|
|
+ score: dimension.score,
|
|
|
+ failures: dimension.evidence.filter((item) => item.outcome === 'fail').map((item) => ({ ruleId: item.ruleId, message: item.message })),
|
|
|
+ })),
|
|
|
+ };
|
|
|
+ let lastError: Error | null = null;
|
|
|
+ for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
|
+ try {
|
|
|
+ const response = await this.client.createChatCompletion({
|
|
|
+ stream: false,
|
|
|
+ model: this.model,
|
|
|
+ temperature: 0,
|
|
|
+ // The selected DeepSeek gateway model emits private reasoning before
|
|
|
+ // the JSON payload. 4k truncates valid responses before the schema;
|
|
|
+ // 16k is a ceiling, not a target, and the per-job budget is capped at 10.
|
|
|
+ max_tokens: 16_000,
|
|
|
+ thinking: { type: 'disabled' },
|
|
|
+ response_format: { type: 'json_object' },
|
|
|
+ messages: [
|
|
|
+ { role: 'system', content: listingAiRubricPrompt() },
|
|
|
+ { role: 'user', content: JSON.stringify(payload) },
|
|
|
+ ],
|
|
|
+ });
|
|
|
+ if (!response.ok) { lastError = new Error(`ai_upstream_${response.status}`); continue; }
|
|
|
+ const body = await response.json() as { choices?: Array<{ message?: { content?: string; reasoning_content?: string } }> };
|
|
|
+ const content = body.choices?.[0]?.message?.content || body.choices?.[0]?.message?.reasoning_content;
|
|
|
+ const parsed = content ? parseListingAiScoreOutput(content) : null;
|
|
|
+ if (parsed) return parsed;
|
|
|
+ lastError = new Error('ai_invalid_score_output');
|
|
|
+ } catch (error) {
|
|
|
+ lastError = error instanceof Error ? error : new Error('ai_upstream_error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw lastError ?? new Error('ai_invalid_score_output');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class ListingAiService {
|
|
|
+ private readonly runningJobs = new Set<string>();
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ readonly repository: ListingAiRepository,
|
|
|
+ private readonly aiScoring?: ListingAiScoringProvider,
|
|
|
+ private readonly now: () => Date = () => new Date(),
|
|
|
+ private readonly concurrency = 3,
|
|
|
+ private readonly maxAiItemsPerJob = 10,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ async catalogSummary(workspaceId: string, platform: 'jd'): Promise<ListingCatalogSummary> {
|
|
|
+ const sources = await this.repository.listAllSources(workspaceId, platform);
|
|
|
+ const latestByProduct = new Map((await this.repository.listLatestScores(workspaceId)).map((score) => [score.productId, score]));
|
|
|
+ const scores = sources.map((source) => {
|
|
|
+ const score = latestByProduct.get(source.productId);
|
|
|
+ return score?.sourceHash === source.sourceHash ? score : null;
|
|
|
+ });
|
|
|
+ const coverage = sources.map(listingCoverage);
|
|
|
+ const numeric = scores.map((score) => score?.overallScore).filter((score): score is number => score !== null && score !== undefined);
|
|
|
+ return {
|
|
|
+ sourceTotal: sources.length,
|
|
|
+ eligible: coverage.filter((item) => item.status === 'eligible').length,
|
|
|
+ scored: scores.filter((score) => score?.overallScore !== null && score?.overallScore !== undefined).length,
|
|
|
+ partial: scores.filter((score) => score?.coverage.status === 'partial' || score?.aiStatus === 'failed').length,
|
|
|
+ blocked: coverage.filter((item) => item.status === 'blocked').length,
|
|
|
+ failed: 0,
|
|
|
+ averageScore: numeric.length ? Math.round((numeric.reduce((sum, value) => sum + value, 0) / numeric.length) * 10) / 10 : null,
|
|
|
+ lastCatalogSyncAt: sources.map((source) => source.syncedAt).sort().at(-1) ?? null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async enqueueScoreJob(input: {
|
|
|
+ workspaceId: string;
|
|
|
+ platform: 'jd';
|
|
|
+ scope: ListingScoreScope;
|
|
|
+ rubricVersion?: string;
|
|
|
+ includeAiSuggestions: boolean;
|
|
|
+ idempotencyKey: string;
|
|
|
+ requestedBy: string;
|
|
|
+ }): Promise<ListingScoreJob> {
|
|
|
+ const sources = await this.resolveScope(input.workspaceId, input.platform, input.scope);
|
|
|
+ if (input.includeAiSuggestions && sources.length > this.maxAiItemsPerJob) {
|
|
|
+ throw new ApiError(429, 'listing_ai_budget_exceeded');
|
|
|
+ }
|
|
|
+ const rubricVersion = input.rubricVersion ?? (input.includeAiSuggestions ? LISTING_AI_RUBRIC_VERSION : LISTING_RUBRIC_VERSION);
|
|
|
+ if (input.includeAiSuggestions && rubricVersion !== LISTING_AI_RUBRIC_VERSION) throw new ApiError(422, 'listing_ai_rubric_required');
|
|
|
+ if (!input.includeAiSuggestions && rubricVersion === LISTING_AI_RUBRIC_VERSION) throw new ApiError(422, 'listing_rule_rubric_required');
|
|
|
+ const requestedAt = this.now().toISOString();
|
|
|
+ const requestHash = canonicalHash({ platform: input.platform, scope: input.scope, rubricVersion, includeAiSuggestions: input.includeAiSuggestions });
|
|
|
+ const job: ListingScoreJob = {
|
|
|
+ id: randomUUID(), workspaceId: input.workspaceId, platform: input.platform,
|
|
|
+ idempotencyKey: input.idempotencyKey, requestHash, rubricVersion,
|
|
|
+ includeAiSuggestions: input.includeAiSuggestions, scope: input.scope, status: sources.length ? 'queued' : 'completed',
|
|
|
+ total: sources.length, processed: 0, succeeded: 0, partial: 0, blocked: 0, failed: 0,
|
|
|
+ requestedBy: input.requestedBy, requestedAt, startedAt: null, completedAt: sources.length ? null : requestedAt, updatedAt: requestedAt,
|
|
|
+ };
|
|
|
+ const items = sources.map<ListingScoreJobItem>((source) => ({
|
|
|
+ id: randomUUID(), jobId: job.id, workspaceId: job.workspaceId, productId: source.productId, sourceHash: source.sourceHash,
|
|
|
+ status: 'queued', attempts: 0, scoreResultId: null, errorCode: null, errorDetail: null, updatedAt: requestedAt,
|
|
|
+ }));
|
|
|
+ const created = await this.repository.createJob(job, items);
|
|
|
+ if (created.created && job.total) queueMicrotask(() => void this.processJob(job.workspaceId, job.id));
|
|
|
+ return created.job;
|
|
|
+ }
|
|
|
+
|
|
|
+ async processJob(workspaceId: string, jobId: string): Promise<void> {
|
|
|
+ if (this.runningJobs.has(jobId)) return;
|
|
|
+ this.runningJobs.add(jobId);
|
|
|
+ try {
|
|
|
+ const job = await this.repository.getJob(workspaceId, jobId);
|
|
|
+ if (!job || job.status === 'cancelled' || job.status === 'completed') return;
|
|
|
+ const startedAt = job.startedAt ?? this.now().toISOString();
|
|
|
+ await this.repository.updateJob({ ...job, status: 'running', startedAt, updatedAt: startedAt });
|
|
|
+ const items = await this.repository.getJobItems(workspaceId, jobId);
|
|
|
+ const pending = items.filter((item) => !['scored', 'partial', 'blocked'].includes(item.status));
|
|
|
+ for (let index = 0; index < pending.length; index += this.concurrency) {
|
|
|
+ const currentJob = await this.repository.getJob(workspaceId, jobId);
|
|
|
+ if (!currentJob || currentJob.status === 'cancelled') break;
|
|
|
+ await Promise.all(pending.slice(index, index + this.concurrency).map((item) => this.processItem(currentJob, item)));
|
|
|
+ await this.recalculateJob(workspaceId, jobId);
|
|
|
+ }
|
|
|
+ await this.recalculateJob(workspaceId, jobId);
|
|
|
+ } finally {
|
|
|
+ this.runningJobs.delete(jobId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Re-enqueues persisted jobs after a process restart. The repository remains
|
|
|
+ * the source of truth, so already-finished items are skipped by processJob.
|
|
|
+ * A database uniqueness constraint keeps score writes idempotent.
|
|
|
+ */
|
|
|
+ async resumePendingJobs(workspaceId: string): Promise<number> {
|
|
|
+ const jobIds = new Set<string>();
|
|
|
+ for (const status of ['queued', 'running'] as const) {
|
|
|
+ let cursor: string | null = null;
|
|
|
+ do {
|
|
|
+ const page = await this.repository.listJobs(workspaceId, status, 100, cursor);
|
|
|
+ for (const job of page.items) jobIds.add(job.id);
|
|
|
+ cursor = page.nextCursor;
|
|
|
+ } while (cursor);
|
|
|
+ }
|
|
|
+ for (const jobId of jobIds) queueMicrotask(() => void this.processJob(workspaceId, jobId));
|
|
|
+ return jobIds.size;
|
|
|
+ }
|
|
|
+
|
|
|
+ async retryJob(workspaceId: string, jobId: string): Promise<ListingScoreJob> {
|
|
|
+ const job = await this.repository.getJob(workspaceId, jobId);
|
|
|
+ if (!job) throw new ApiError(404, 'score_job_not_found');
|
|
|
+ if (!['partial', 'failed'].includes(job.status)) throw new ApiError(409, 'score_job_not_retryable');
|
|
|
+ const items = await this.repository.getJobItems(workspaceId, jobId);
|
|
|
+ for (const item of items.filter((candidate) => ['failed', 'partial'].includes(candidate.status))) {
|
|
|
+ await this.repository.updateJobItem({ ...item, status: 'queued', errorCode: null, errorDetail: null, updatedAt: this.now().toISOString() });
|
|
|
+ }
|
|
|
+ const next = { ...job, status: 'queued' as const, completedAt: null, updatedAt: this.now().toISOString() };
|
|
|
+ await this.repository.updateJob(next);
|
|
|
+ queueMicrotask(() => void this.processJob(workspaceId, jobId));
|
|
|
+ return next;
|
|
|
+ }
|
|
|
+
|
|
|
+ async cancelJob(workspaceId: string, jobId: string): Promise<ListingScoreJob> {
|
|
|
+ const job = await this.repository.getJob(workspaceId, jobId);
|
|
|
+ if (!job) throw new ApiError(404, 'score_job_not_found');
|
|
|
+ if (!['queued', 'running'].includes(job.status)) throw new ApiError(409, 'score_job_not_cancellable');
|
|
|
+ const now = this.now().toISOString();
|
|
|
+ const next = { ...job, status: 'cancelled' as const, completedAt: now, updatedAt: now };
|
|
|
+ await this.repository.updateJob(next);
|
|
|
+ return next;
|
|
|
+ }
|
|
|
+
|
|
|
+ async createVersion(input: {
|
|
|
+ workspaceId: string; platform: 'jd'; productId: string; baseSourceHash: string; baseScoreResultId: string | null;
|
|
|
+ content?: ListingVersion['content'] | undefined; createdBy: string;
|
|
|
+ }): Promise<ListingVersion> {
|
|
|
+ const source = await this.repository.getSource(input.workspaceId, input.platform, input.productId);
|
|
|
+ if (!source) throw new ApiError(404, 'listing_product_not_found');
|
|
|
+ if (source.sourceHash !== input.baseSourceHash) throw new ApiError(409, 'listing_source_changed');
|
|
|
+ const score = input.baseScoreResultId
|
|
|
+ ? await this.repository.getLatestScore(input.workspaceId, input.productId)
|
|
|
+ : null;
|
|
|
+ const candidate = score?.id === input.baseScoreResultId && score.sourceHash === input.baseSourceHash
|
|
|
+ ? score.aiCandidate
|
|
|
+ : null;
|
|
|
+ if (!input.content && !candidate) throw new ApiError(422, 'listing_ai_candidate_missing');
|
|
|
+ const version: ListingVersion = {
|
|
|
+ id: randomUUID(), workspaceId: input.workspaceId, productId: input.productId, versionNo: 0,
|
|
|
+ baseSourceHash: input.baseSourceHash, baseScoreResultId: input.baseScoreResultId,
|
|
|
+ content: input.content ?? candidate!,
|
|
|
+ status: 'draft', createdBy: input.createdBy, createdAt: this.now().toISOString(), adoptedAt: null,
|
|
|
+ };
|
|
|
+ return this.repository.createVersion(version);
|
|
|
+ }
|
|
|
+
|
|
|
+ async adoptVersion(workspaceId: string, platform: 'jd', versionId: string): Promise<ListingVersion> {
|
|
|
+ const version = await this.repository.getVersion(workspaceId, versionId);
|
|
|
+ if (!version) throw new ApiError(404, 'listing_version_not_found');
|
|
|
+ const source = await this.repository.getSource(workspaceId, platform, version.productId);
|
|
|
+ if (!source || source.sourceHash !== version.baseSourceHash) throw new ApiError(409, 'listing_source_changed');
|
|
|
+ const next = { ...version, status: 'adopted' as const, adoptedAt: this.now().toISOString() };
|
|
|
+ await this.repository.updateVersion(next);
|
|
|
+ return next;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async resolveScope(workspaceId: string, platform: 'jd', scope: ListingScoreScope): Promise<ListingSourceSnapshot[]> {
|
|
|
+ const sources = await this.repository.listAllSources(workspaceId, platform);
|
|
|
+ if (scope.mode === 'selected') {
|
|
|
+ const selected = new Set(scope.productIds);
|
|
|
+ const resolved = sources.filter((source) => selected.has(source.productId));
|
|
|
+ if (resolved.length !== selected.size) throw new ApiError(422, 'listing_source_incomplete');
|
|
|
+ return resolved;
|
|
|
+ }
|
|
|
+ const selectedIds = new Set<string>();
|
|
|
+ let cursor: string | null = null;
|
|
|
+ do {
|
|
|
+ const result = await this.repository.listProducts({ workspaceId, platform, ...scope.filter, limit: 100, cursor, sort: 'productId' });
|
|
|
+ for (const item of result.items) selectedIds.add(item.productId);
|
|
|
+ cursor = result.nextCursor;
|
|
|
+ } while (cursor);
|
|
|
+ return sources.filter((source) => selectedIds.has(source.productId));
|
|
|
+ }
|
|
|
+
|
|
|
+ private matchesFilter(source: ListingSourceSnapshot, filter: ListingProductFilter): boolean {
|
|
|
+ const search = filter.search?.trim().toLocaleLowerCase();
|
|
|
+ if (search && !`${source.productId} ${source.title ?? ''}`.toLocaleLowerCase().includes(search)) return false;
|
|
|
+ if (filter.categoryId && !source.categoryIds.includes(filter.categoryId)) return false;
|
|
|
+ if (filter.itemStatus && source.itemStatus !== filter.itemStatus) return false;
|
|
|
+ if (filter.coverageStatus && listingCoverage(source).status !== filter.coverageStatus) return false;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async processItem(job: ListingScoreJob, item: ListingScoreJobItem): Promise<void> {
|
|
|
+ const now = this.now().toISOString();
|
|
|
+ try {
|
|
|
+ const source = await this.repository.getSource(job.workspaceId, job.platform, item.productId);
|
|
|
+ if (!source || source.sourceHash !== item.sourceHash) {
|
|
|
+ await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, errorCode: 'listing_source_changed', errorDetail: 'Source snapshot is missing or changed', updatedAt: now });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (job.includeAiSuggestions) {
|
|
|
+ const cached = await this.repository.getLatestScore(job.workspaceId, item.productId, LISTING_AI_RUBRIC_VERSION);
|
|
|
+ if (cached?.sourceHash === source.sourceHash && cached.aiStatus === 'completed' && cached.model === this.aiScoring?.model && cached.promptVersion === LISTING_AI_PROMPT_VERSION) {
|
|
|
+ const cachedStatus: ListingScoreJobItem['status'] = cached.overallScore === null ? 'partial' : 'scored';
|
|
|
+ await this.repository.updateJobItem({ ...item, status: cachedStatus, attempts: item.attempts + 1, scoreResultId: cached.id, errorCode: null, errorDetail: null, updatedAt: now });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const baseline = scoreListing(source, { rubricVersion: LISTING_RUBRIC_VERSION, now });
|
|
|
+ if (baseline.coverage.status === 'blocked' || baseline.overallScore === null) {
|
|
|
+ await this.repository.updateJobItem({ ...item, status: 'blocked', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_ai_source_incomplete', errorDetail: 'AI scoring requires all five baseline dimensions', updatedAt: now });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!this.aiScoring?.configured) {
|
|
|
+ await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_ai_not_configured', errorDetail: 'AI scoring gateway is not configured', updatedAt: now });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const output = await this.aiScoring.score(source, baseline);
|
|
|
+ const result = composeListingAiScore({ baseline, output, model: this.aiScoring.model, now });
|
|
|
+ const saved = await this.repository.saveScore(result);
|
|
|
+ const status: ListingScoreJobItem['status'] = result.overallScore === null ? 'partial' : 'scored';
|
|
|
+ await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, scoreResultId: saved.id, errorCode: status === 'partial' ? 'listing_ai_partial' : null, errorDetail: null, updatedAt: now });
|
|
|
+ return;
|
|
|
+ } catch (error) {
|
|
|
+ await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: error instanceof Error ? error.message.slice(0, 80) : 'ai_upstream_error', errorDetail: 'AI score generation failed; existing rule score was retained', updatedAt: now });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const previous = await this.repository.getLatestScore(job.workspaceId, item.productId, job.rubricVersion);
|
|
|
+ const result = scoreListing(source, {
|
|
|
+ ...(previous?.sourceHash === source.sourceHash ? { id: previous.id } : {}),
|
|
|
+ rubricVersion: job.rubricVersion,
|
|
|
+ now,
|
|
|
+ });
|
|
|
+ const status: ListingScoreJobItem['status'] = result.coverage.status === 'blocked' ? 'blocked' : result.overallScore === null ? 'partial' : 'scored';
|
|
|
+ const saved = await this.repository.saveScore(result);
|
|
|
+ await this.repository.updateJobItem({ ...item, status, attempts: item.attempts + 1, scoreResultId: saved.id, errorCode: null, errorDetail: null, updatedAt: now });
|
|
|
+ } catch (error) {
|
|
|
+ await this.repository.updateJobItem({ ...item, status: 'failed', attempts: item.attempts + 1, scoreResultId: null, errorCode: 'listing_score_failed', errorDetail: error instanceof Error ? error.message.slice(0, 200) : 'Unknown scoring failure', updatedAt: now });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async recalculateJob(workspaceId: string, jobId: string): Promise<void> {
|
|
|
+ const job = await this.repository.getJob(workspaceId, jobId);
|
|
|
+ if (!job || job.status === 'cancelled') return;
|
|
|
+ const items = await this.repository.getJobItems(workspaceId, jobId);
|
|
|
+ const succeeded = items.filter((item) => item.status === 'scored').length;
|
|
|
+ const partial = items.filter((item) => item.status === 'partial').length;
|
|
|
+ const blocked = items.filter((item) => item.status === 'blocked').length;
|
|
|
+ const failed = items.filter((item) => item.status === 'failed').length;
|
|
|
+ const processed = succeeded + partial + blocked + failed;
|
|
|
+ const completedAt = processed === job.total ? this.now().toISOString() : null;
|
|
|
+ const status = processed < job.total ? 'running' : failed === job.total ? 'failed' : partial || blocked || failed ? 'partial' : 'completed';
|
|
|
+ await this.repository.updateJob({ ...job, status, processed, succeeded, partial, blocked, failed, completedAt, updatedAt: this.now().toISOString() });
|
|
|
+ }
|
|
|
+}
|