mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
Improve import attribute hints
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
createItem as createItemRepo,
|
createItem as createItemRepo,
|
||||||
deleteItem as deleteItemRepo,
|
deleteItem as deleteItemRepo,
|
||||||
getItemById,
|
getItemById,
|
||||||
|
getProjectAttributeExamples,
|
||||||
listItems as listItemsRepo,
|
listItems as listItemsRepo,
|
||||||
updateItem as updateItemRepo
|
updateItem as updateItemRepo
|
||||||
} from '../db/items-repository.js';
|
} from '../db/items-repository.js';
|
||||||
@@ -26,8 +27,10 @@ import { readUrlMarkdown } from '../services/url-reader-service.js';
|
|||||||
import { normalizeUrl } from '../utils/url-normalizer.js';
|
import { normalizeUrl } from '../utils/url-normalizer.js';
|
||||||
import {
|
import {
|
||||||
extractItemImportDetails,
|
extractItemImportDetails,
|
||||||
parseCachedItemImportDetails
|
parseCachedItemImportDetails,
|
||||||
|
ItemImportDetails
|
||||||
} from '../services/item-import-service.js';
|
} from '../services/item-import-service.js';
|
||||||
|
import { getProjectById } from '../db/projects-repository.js';
|
||||||
|
|
||||||
export const listItems = async (req: Request, res: Response) => {
|
export const listItems = async (req: Request, res: Response) => {
|
||||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
@@ -156,7 +159,11 @@ export const deleteItem = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
export const importItemFromUrl = async (req: Request, res: Response) => {
|
export const importItemFromUrl = async (req: Request, res: Response) => {
|
||||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
void projectId;
|
const project = await getProjectById(projectId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new HttpError(404, 'Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
const { url } = parseItemImportPayload(req.body);
|
const { url } = parseItemImportPayload(req.body);
|
||||||
let normalizedUrl = url;
|
let normalizedUrl = url;
|
||||||
@@ -198,9 +205,21 @@ export const importItemFromUrl = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cachedDetails = parseCachedItemImportDetails(urlRecord?.attributes);
|
const cachedDetails = parseCachedItemImportDetails(urlRecord?.attributes);
|
||||||
const details = cachedDetails
|
let details: ItemImportDetails;
|
||||||
? cachedDetails
|
|
||||||
: await extractItemImportDetails({ normalizedUrl, markdown });
|
if (cachedDetails) {
|
||||||
|
details = cachedDetails;
|
||||||
|
} else {
|
||||||
|
const attributeExamples = await getProjectAttributeExamples(projectId);
|
||||||
|
details = await extractItemImportDetails({
|
||||||
|
normalizedUrl,
|
||||||
|
markdown,
|
||||||
|
attributeHints: {
|
||||||
|
projectAttributeNames: project.attributes,
|
||||||
|
projectAttributeExamples: attributeExamples
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!cachedDetails) {
|
if (!cachedDetails) {
|
||||||
if (urlRecord) {
|
if (urlRecord) {
|
||||||
|
|||||||
@@ -353,3 +353,112 @@ export const deleteItem = async (id: string): Promise<boolean> => {
|
|||||||
|
|
||||||
return (result.rowCount ?? 0) > 0;
|
return (result.rowCount ?? 0) > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ProjectAttributeExample {
|
||||||
|
key: string;
|
||||||
|
sampleValues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectAttributeExampleOptions {
|
||||||
|
itemLimit?: number;
|
||||||
|
attributeLimit?: number;
|
||||||
|
valuesPerAttribute?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProjectAttributeExamples = async (
|
||||||
|
projectId: string,
|
||||||
|
options?: ProjectAttributeExampleOptions
|
||||||
|
): Promise<ProjectAttributeExample[]> => {
|
||||||
|
const itemLimit = Math.max(1, Math.min(options?.itemLimit ?? 50, 200));
|
||||||
|
const attributeLimit = Math.max(1, Math.min(options?.attributeLimit ?? 15, 100));
|
||||||
|
const valuesPerAttribute = Math.max(1, Math.min(options?.valuesPerAttribute ?? 5, 10));
|
||||||
|
|
||||||
|
const result = await query<{ attributes: Record<string, unknown> }>(
|
||||||
|
`
|
||||||
|
SELECT attributes
|
||||||
|
FROM items
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[projectId, itemLimit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const attributeOrder: string[] = [];
|
||||||
|
const valueMap = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
const formatValue = (value: unknown): string | null => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
if (!json || json === '[]' || json === '{}') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.length > 120 ? `${json.slice(0, 117)}...` : json;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const attributes = row.attributes ?? {};
|
||||||
|
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(attributes)) {
|
||||||
|
if (typeof rawKey !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = rawKey.trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueSet = valueMap.get(key);
|
||||||
|
if (!valueSet) {
|
||||||
|
if (attributeOrder.length >= attributeLimit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
valueSet = new Set<string>();
|
||||||
|
valueMap.set(key, valueSet);
|
||||||
|
attributeOrder.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueSet.size >= valuesPerAttribute) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedValue = formatValue(rawValue);
|
||||||
|
if (!formattedValue || valueSet.has(formattedValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
valueSet.add(formattedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributeOrder.map((key) => ({
|
||||||
|
key,
|
||||||
|
sampleValues: Array.from(valueMap.get(key) ?? [])
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ interface LlmImportExtractionResult {
|
|||||||
images?: Array<{ url?: string | null; alt?: string | null }> | null;
|
images?: Array<{ url?: string | null; alt?: string | null }> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AttributeHintSample {
|
||||||
|
key: string;
|
||||||
|
sampleValues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectAttributeHints {
|
||||||
|
projectAttributeNames?: string[];
|
||||||
|
projectAttributeExamples?: AttributeHintSample[];
|
||||||
|
}
|
||||||
|
|
||||||
const toSnakeCaseKey = (value: string): string => {
|
const toSnakeCaseKey = (value: string): string => {
|
||||||
return value
|
return value
|
||||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||||
@@ -140,24 +150,75 @@ export const parseCachedItemImportDetails = (raw: unknown): ItemImportDetails |
|
|||||||
|
|
||||||
export const extractItemImportDetails = async ({
|
export const extractItemImportDetails = async ({
|
||||||
normalizedUrl,
|
normalizedUrl,
|
||||||
markdown
|
markdown,
|
||||||
|
attributeHints
|
||||||
}: {
|
}: {
|
||||||
normalizedUrl: string;
|
normalizedUrl: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
|
attributeHints?: ProjectAttributeHints;
|
||||||
}): Promise<ItemImportDetails> => {
|
}): Promise<ItemImportDetails> => {
|
||||||
const llmExtraction = await requestStructuredLlmResponse<LlmImportExtractionResult>({
|
const preferredAttributeNames = (attributeHints?.projectAttributeNames ?? [])
|
||||||
systemPrompt:
|
.map((name) => name.trim())
|
||||||
'You extract structured product information from markdown content. ' +
|
.filter((name) => name.length > 0)
|
||||||
'Return only facts explicitly supported by the text. Never invent details.',
|
.slice(0, 12);
|
||||||
prompt: [
|
|
||||||
|
const attributeExampleLines = (attributeHints?.projectAttributeExamples ?? [])
|
||||||
|
.map((example) => {
|
||||||
|
const key = example.key.trim();
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = example.sampleValues
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `- ${key}: ${values.join(' | ')}`;
|
||||||
|
})
|
||||||
|
.filter((line): line is string => Boolean(line))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const promptSections: string[] = [
|
||||||
'Extract product details for cataloging.',
|
'Extract product details for cataloging.',
|
||||||
`Source URL: ${normalizedUrl}`,
|
`Source URL: ${normalizedUrl}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (preferredAttributeNames.length > 0 || attributeExampleLines.length > 0) {
|
||||||
|
promptSections.push('Project attribute context:');
|
||||||
|
|
||||||
|
if (preferredAttributeNames.length > 0) {
|
||||||
|
promptSections.push(`Preferred attribute keys: ${preferredAttributeNames.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributeExampleLines.length > 0) {
|
||||||
|
promptSections.push('Existing item attribute examples:');
|
||||||
|
promptSections.push(...attributeExampleLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
promptSections.push(
|
||||||
|
'When the content maps to these attributes, reuse the exact key. Only introduce new keys when necessary.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
promptSections.push(
|
||||||
'Provide the best available manufacturer, model, concise note (if applicable), key attributes, and any image URLs present.',
|
'Provide the best available manufacturer, model, concise note (if applicable), key attributes, and any image URLs present.',
|
||||||
'Markdown content:',
|
'Markdown content:',
|
||||||
'```markdown',
|
'```markdown',
|
||||||
markdown,
|
markdown,
|
||||||
'```'
|
'```'
|
||||||
].join('\n'),
|
);
|
||||||
|
|
||||||
|
const llmExtraction = await requestStructuredLlmResponse<LlmImportExtractionResult>({
|
||||||
|
systemPrompt:
|
||||||
|
'You extract structured product information for a catalog from markdown content. ' +
|
||||||
|
'Use the provided project attribute context to align attribute names. ' +
|
||||||
|
'Return only facts explicitly supported by the text. Never invent details.',
|
||||||
|
prompt: promptSections.join('\n'),
|
||||||
responseSchema: {
|
responseSchema: {
|
||||||
name: 'product_import',
|
name: 'product_import',
|
||||||
schema: {
|
schema: {
|
||||||
@@ -180,7 +241,7 @@ export const extractItemImportDetails = async ({
|
|||||||
attributes: {
|
attributes: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
'Key-value attributes derived from the content (e.g., price, dimensions, materials).',
|
'Key-value attributes derived from the content (e.g., price, dimensions, materials). Prefer attribute keys that match the provided project context.',
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
anyOf: [
|
anyOf: [
|
||||||
{ type: 'string' },
|
{ type: 'string' },
|
||||||
|
|||||||
Reference in New Issue
Block a user