fix(customers): normalize structured ai product values

This commit is contained in:
wmq
2026-08-03 02:04:13 +08:00
parent 45b94374b0
commit a7c894138e
5 changed files with 65 additions and 14 deletions
+44 -7
View File
@@ -759,8 +759,14 @@ async function analyzeAndPersistConversation(tenantId, conversationId) {
...customer,
company: String(result.aiProfile?.companyName || '').trim() || customer.company,
language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language,
products: Array.from(new Set([...(customer.products || []), ...(result.products || [])])),
tags: Array.from(new Set([...(customer.tags || []), ...(result.tags || [])])),
products: Array.from(new Set(normalizedTextList(
[...(customer.products || []), ...(Array.isArray(result.products) ? result.products : [])],
['name', 'product', 'category'],
))),
tags: Array.from(new Set(normalizedTextList(
[...(customer.tags || []), ...(Array.isArray(result.tags) ? result.tags : [])],
['label', 'name', 'category'],
))),
intentScore,
intentLevel: result.intentLevel || customer.intentLevel || 'low',
stage: result.stage || customer.stage || 'initial',
@@ -777,7 +783,7 @@ async function analyzeAndPersistConversation(tenantId, conversationId) {
? result.aiProfile.relationshipEvidence.map(String).filter(Boolean).slice(0, 3)
: [],
summary: String(result.summary || ''),
nextActions: Array.isArray(result.nextActions) ? result.nextActions : [],
nextActions: normalizedTextList(result.nextActions, ['action', 'text', 'label', 'name']),
profileVersion: 3,
},
aiAnalyzedAt: new Date().toISOString(),
@@ -1125,6 +1131,18 @@ const kinds = {
customers: 'customers', opportunities: 'opportunities', tasks: 'tasks', risks: 'risks',
knowledge: 'knowledge',
}
function normalizedTextList(values, preferredKeys = []) {
if (!Array.isArray(values)) return []
return values.map((item) => {
if (typeof item === 'string' || typeof item === 'number') return String(item).trim()
if (!item || typeof item !== 'object') return ''
for (const key of preferredKeys) {
const value = item[key]
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
}
return ''
}).filter(Boolean)
}
function withDefaults(kind, body) {
if (kind === 'customers') {
const source = body && typeof body === 'object' ? body : {}
@@ -1135,15 +1153,15 @@ function withDefaults(kind, body) {
valueLevel: 'D', isKey: false, ownerId: null,
aiProfile: { summary: '', keyPoints: [], nextActions: [] },
...source,
products: Array.isArray(source.products) ? source.products : [],
tags: Array.isArray(source.tags) ? source.tags : [],
products: normalizedTextList(source.products, ['name', 'product', 'category']),
tags: normalizedTextList(source.tags, ['label', 'name', 'category']),
aiProfile: {
summary: '',
keyPoints: [],
nextActions: [],
...(source.aiProfile && typeof source.aiProfile === 'object' ? source.aiProfile : {}),
keyPoints: Array.isArray(source.aiProfile?.keyPoints) ? source.aiProfile.keyPoints : [],
nextActions: Array.isArray(source.aiProfile?.nextActions) ? source.aiProfile.nextActions : [],
keyPoints: normalizedTextList(source.aiProfile?.keyPoints, ['text', 'label', 'name']),
nextActions: normalizedTextList(source.aiProfile?.nextActions, ['action', 'text', 'label', 'name']),
},
}
return {
@@ -1155,10 +1173,26 @@ function withDefaults(kind, body) {
}
return body
}
async function normalizeLegacyCustomerResources() {
const { rows } = await pool.query(
"SELECT tenant_id, data FROM resources WHERE kind='customers'",
)
let updated = 0
for (const row of rows) {
const current = row.data
const normalized = withDefaults('customers', current)
if (JSON.stringify(current) === JSON.stringify(normalized)) continue
await putResource(String(row.tenant_id), 'customers', normalized)
updated++
}
if (updated) console.log(`Normalized ${updated} legacy customer records`)
}
for (const [path, kind] of Object.entries(kinds)) {
app.get(`/api/${path}`, async (req, res) => {
let list = await listResources(req.auth.tenantId, kind)
if (kind === 'customers') {
list = list.map((customer) => withDefaults('customers', customer))
list = await Promise.all(list.map(async (customer) => {
const country = resolveCustomerCountry(customer, customer)
if (
@@ -5650,6 +5684,9 @@ async function runMessagingIntegrityMaintenance() {
if (weeklyDue) await reconcileConnectedMessagingChannels({ full: true, refreshProfiles: true })
}
setTimeout(() => void normalizeLegacyCustomerResources().catch((error) => {
console.error(JSON.stringify({ message: 'Customer shape normalization failed', error: error.message }))
}), 1_000).unref?.()
setTimeout(() => void reconcileConnectedMessagingChannels(), 15_000).unref?.()
setInterval(
() => void reconcileConnectedMessagingChannels(),
+3 -2
View File
@@ -31,6 +31,7 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
import { toast, errorMessage } from '@/app/store/toast'
import { cn } from '@/utils/cn'
import { formatProductName } from '@/utils/format'
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
@@ -151,8 +152,8 @@ export function InsightPanel({
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{customer.products?.map((p) => (
<Badge key={p} variant="primary">{p}</Badge>
{customer.products?.map((p, index) => (
<Badge key={`${formatProductName(p)}-${index}`} variant="primary">{formatProductName(p)}</Badge>
))}
{customer.tags?.map((t) => (
<CustomerSemanticTag key={t} tag={t} />
+4 -3
View File
@@ -48,6 +48,7 @@ import {
INTENT_LEVELS,
} from '@/config/constants'
import { cn } from '@/utils/cn'
import { formatProductName } from '@/utils/format'
import type { Customer } from '@/types'
import type { ChannelType } from '@/types/messaging'
@@ -285,7 +286,7 @@ export function CustomersPage() {
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{formatProductName(customer.products?.[0]) || '—'}</span></Td>
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
<Td><CustomerStageBadge stage={customer.stage} /></Td>
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
@@ -333,8 +334,8 @@ export function CustomersPage() {
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
</div>
{customer.products.length > 0 && (
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p>
{customer.products?.length > 0 && (
<p className="mt-2 text-xs text-muted-foreground truncate">{formatProductName(customer.products[0])}</p>
)}
</Card>
))}
+2 -2
View File
@@ -30,7 +30,7 @@ import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n'
import { getPlan } from '@/config/pricingPlans'
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
import { formatDateTime, formatRelative } from '@/utils/format'
import { formatDateTime, formatProductName, formatRelative } from '@/utils/format'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { Card, CardHeader } from '@/components/ui/card'
@@ -235,7 +235,7 @@ export function DashboardPage() {
<CountryFlag code={c.country} withName={false} />
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{c.products?.[0] ?? '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
{formatProductName(c.products?.[0]) || '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
+12
View File
@@ -71,3 +71,15 @@ export function localTimeByOffset(offsetHours: number): string {
const target = new Date(utc + offsetHours * 3600000)
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
}
export function formatProductName(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
if (!value || typeof value !== 'object') return ''
const product = value as Record<string, unknown>
for (const key of ['name', 'product', 'category']) {
if (typeof product[key] === 'string' || typeof product[key] === 'number') {
return String(product[key]).trim()
}
}
return ''
}