Files
xinmi/internal/application/service/chat_pipeline/extract_entity_price.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

277 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package chatpipeline
import (
"fmt"
"regexp"
"strings"
"github.com/Tencent/WeKnora/internal/types"
)
var graphPriceLabelPattern = regexp.MustCompile(`(?:终端)?零售(?:价格|价)|批发(?:价格|价)|促销(?:价格|价)|优惠(?:价格|价)|活动(?:价格|价)|会员(?:价格|价)|建议零售价|销售价格|售价|单价|原价|现价|价格`)
var graphPriceAmountPattern = regexp.MustCompile(`^(人民币|[¥¥$])?([0-9]+(?:\.[0-9]+)?)(美元|人民币|元|角|分|盒|箱|瓶|套|支|粒|片|件|袋|kg|mg|ml|g)?(/(?:[0-9一二两俩三四五六七八九十百千]+)?(?:盒|箱|瓶|套|支|粒|片|件|袋|kg|mg|ml|g))?`)
var graphPriceHeaderUnitPattern = regexp.MustCompile(`^[((](元|美元|元/(?:盒|箱|瓶|套|支|件))[))]$`)
type graphPriceValue struct {
number string
unit string
}
type graphPriceClaim struct {
label string
values []graphPriceValue
prefix string
hasQuantity bool
}
func graphPriceValues(value, headerUnit string) []graphPriceValue {
value = strings.TrimLeft(compactGraphText(value), "::为是")
var values []graphPriceValue
for value != "" {
match := graphPriceAmountPattern.FindStringSubmatch(value)
if match == nil {
// Do not accept a parsed prefix while silently ignoring a changed
// package quantity or another amount later in the same price claim.
if strings.HasPrefix(value, "/") || graphQuantityPattern.MatchString(value) {
return nil
}
break
}
unit := match[3] + match[4]
if match[1] != "" {
currency := "元"
if match[1] == "$" {
currency = "美元"
}
if unit == "" || strings.HasPrefix(unit, "/") {
unit = currency + unit
}
}
if unit == "" {
unit = headerUnit
}
values = append(values, graphPriceValue{number: match[2], unit: unit})
value = strings.TrimLeft(value[len(match[0]):], ",,、")
}
return values
}
func graphPriceClaims(value string) []graphPriceClaim {
value = compactGraphText(value)
labels := graphPriceLabelPattern.FindAllStringIndex(value, -1)
var claims []graphPriceClaim
for i, span := range labels {
end := len(value)
if i+1 < len(labels) {
end = labels[i+1][0]
}
prefix := value[:span[0]]
if index := strings.LastIndexAny(prefix, ",,;;。|::"); index >= 0 {
prefix = strings.TrimLeft(prefix[index:], ",,;;。|::")
}
claims = append(claims, graphPriceClaim{
label: graphPriceLabel(value[span[0]:span[1]]),
values: graphPriceValues(value[span[1]:end], ""),
prefix: prefix,
hasQuantity: graphQuantityPattern.MatchString(value[span[1]:end]),
})
}
return claims
}
func graphPriceLabel(value string) string {
return strings.TrimPrefix(strings.ReplaceAll(value, "价格", "价"), "终端")
}
func graphPriceValuesMatch(want, available []graphPriceValue) bool {
if len(want) == 0 || len(want) > len(available) {
return false
}
for start := 0; start+len(want) <= len(available); start++ {
matches := true
for i, value := range want {
actual := available[start+i]
if value.number != actual.number || (value.unit != "" && value.unit != actual.unit) {
matches = false
break
}
}
if matches {
return true
}
}
return false
}
func graphPricePrefixMatches(prefix, name string, inherited bool) bool {
prefix = strings.TrimSuffix(compactGraphText(prefix), "的")
if prefix == compactGraphText(name) {
return true
}
return inherited && (prefix == "" || prefix == "本品" || prefix == "该产品" || prefix == "其")
}
func graphPriceSentenceSupports(sentence, name string, inherited bool, want graphPriceClaim) bool {
for _, claim := range graphPriceClaims(sentence) {
if !graphPricePrefixMatches(claim.prefix, name, inherited) {
inherited = false
continue
}
inherited = true
if claim.label == want.label && graphPriceValuesMatch(want.values, claim.values) {
return true
}
}
return false
}
func graphPriceTableCells(line string) []string {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "|") {
return nil
}
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(line, "|"), "|"), "|")
}
func graphPriceProductHeader(value string) bool {
switch compactGraphText(value) {
case "产品名称", "商品名称", "品名", "产品":
return true
default:
return false
}
}
func graphPriceTableRowMatches(cells, headers []string, name string) bool {
name = compactGraphText(name)
if name == "" {
return false
}
if len(headers) == 0 {
for _, cell := range cells {
if compactGraphText(cell) == name {
return true
}
}
return false
}
if len(cells) > len(headers) {
return false
}
productColumn, brandColumn := -1, -1
for i, header := range headers {
if graphPriceProductHeader(header) {
if productColumn >= 0 {
return false
}
productColumn = i
}
if header = compactGraphText(header); header == "品牌" || header == "品牌名称" {
if brandColumn >= 0 {
return false
}
brandColumn = i
}
}
if productColumn < 0 || productColumn >= len(cells) {
return false
}
product := compactGraphText(cells[productColumn])
if product == "" {
return false
}
if product == name {
return true
}
if brandColumn < 0 || brandColumn >= len(cells) {
return false
}
brand := compactGraphText(cells[brandColumn])
return brand != "" && brand+product == name
}
func graphPriceAttributeSupported(content, name string, want graphPriceClaim) bool {
var headers []string
headingMatches := false
for _, line := range strings.Split(content, "\n") {
if cells := graphPriceTableCells(line); cells != nil {
headingMatches = false
isHeader := false
for _, cell := range cells {
if graphPriceProductHeader(cell) {
isHeader = true
}
}
if isHeader {
headers = cells
continue
}
// A short row may end midway through its final cell. Only closed
// cells have a known boundary; never validate a truncated price.
if len(cells) < len(headers) && !strings.HasSuffix(strings.TrimSpace(line), "|") {
cells = cells[:len(cells)-1]
}
if !graphPriceTableRowMatches(cells, headers, name) {
continue
}
for i, cell := range cells {
if graphPriceSentenceSupports(cell, name, true, want) {
return true
}
// A chunk may end before the row's final cells; only existing
// leading cells can inherit their corresponding explicit header.
if i >= len(headers) {
continue
}
header := compactGraphText(headers[i])
span := graphPriceLabelPattern.FindStringIndex(header)
if span == nil || span[0] != 0 || graphPriceLabel(header[:span[1]]) != want.label {
continue
}
unit := ""
if suffix := header[span[1]:]; suffix != "" {
match := graphPriceHeaderUnitPattern.FindStringSubmatch(suffix)
if match == nil {
continue
}
unit = match[1]
}
if graphPriceValuesMatch(want.values, graphPriceValues(cell, unit)) {
return true
}
}
continue
}
headers = nil
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || graphProductNameMarkerPattern.MatchString(trimmed) {
heading := strings.TrimLeft(trimmed, "# ")
heading = strings.TrimPrefix(graphProductIdentity(heading), "产品名称")
headingMatches = heading == graphProductIdentity(name)
continue
}
for _, sentence := range strings.FieldsFunc(line, func(r rune) bool { return strings.ContainsRune("。!?!?;;", r) }) {
if graphPriceSentenceSupports(sentence, name, headingMatches, want) {
return true
}
}
}
return false
}
func validateGraphPriceAttributes(graph *types.GraphData, content string) error {
for i, node := range graph.Node {
for j, attribute := range node.Attributes {
for _, claim := range graphPriceClaims(attribute) {
if !claim.hasQuantity {
continue
}
if !graphPriceAttributeSupported(content, node.Name, claim) {
return fmt.Errorf("%w: entity %d attribute %d has an unsupported price label/value (%s); require an explicit label and matching amount/unit in this product's sentence or table column, never infer unheaded prices", ErrGraphNeedsReview, i+1, j+1, claim.label)
}
}
}
}
return nil
}