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
398 lines
15 KiB
Go
398 lines
15 KiB
Go
package chatpipeline
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"github.com/Tencent/WeKnora/internal/types"
|
||
)
|
||
|
||
// These are labelled source lists, not medical classifications. All offsets
|
||
// refer to the unchanged chunk; normalized text is used only for comparison.
|
||
var graphListHeadingPattern = regexp.MustCompile(`(?m)[〔〖【\[][^〔〖【\[〕〗】\]\n]{1,40}[〕〗】\]]|^[ \t]*[^\n::〔〖【\[]{1,30}[::]`)
|
||
var graphListPredicatePattern = regexp.MustCompile(`谨\s*慎\s*使\s*用|慎\s*[用甩](?:\s*于)?|不\s*适\s*[宜用合](?:\s*于)?|不\s*宜(?:\s*于)?|禁\s*止\s*使\s*用|禁\s*用(?:\s*于)?|不\s*[得可]\s*使\s*用|不\s*能\s*使\s*用`)
|
||
var graphListAmbiguityPattern = regexp.MustCompile(`[和及与或但若如除仅]|以及|同时|慎|禁|不适|使用|服用|适用|适宜|用于`)
|
||
|
||
type graphRestrictionMember struct {
|
||
start, end int
|
||
target string
|
||
sourceTarget string
|
||
predicate string
|
||
label string
|
||
explicit bool
|
||
ambiguous bool
|
||
}
|
||
|
||
type graphRestrictionScope struct {
|
||
start, end, headingEnd int
|
||
section int
|
||
ownerText string
|
||
heading string
|
||
label string
|
||
members []graphRestrictionMember
|
||
ambiguous bool
|
||
}
|
||
|
||
func graphRestrictionLabel(predicate string) string {
|
||
switch strings.TrimSuffix(graphComparisonText(predicate), "于") {
|
||
case "慎用", "慎甩", "谨慎使用":
|
||
return "慎用于"
|
||
case "不适宜", "不适用", "不适合", "不宜":
|
||
return "不适用于"
|
||
case "禁用", "禁止使用", "不得使用", "不可使用", "不能使用":
|
||
return "禁用于"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func graphRestrictionHeadingLabel(heading string) (string, bool) {
|
||
name := strings.Trim(graphComparisonText(heading), "〔〖【[〕〗】]::")
|
||
for _, suffix := range []string{"人群", "对象"} {
|
||
if !strings.HasSuffix(name, suffix) {
|
||
continue
|
||
}
|
||
predicate := strings.TrimSuffix(name, suffix)
|
||
label := graphRestrictionLabel(predicate)
|
||
// An OCR-damaged restrictive heading stays a literal block. It must not
|
||
// lend a guessed typed predicate to its members.
|
||
return label, label != "" || strings.HasPrefix(predicate, "不") ||
|
||
strings.HasPrefix(predicate, "慎") || strings.HasPrefix(predicate, "禁")
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func graphTrimSpan(content string, start, end int) (int, int) {
|
||
text := content[start:end]
|
||
left := strings.TrimLeftFunc(text, unicode.IsSpace)
|
||
start += len(text) - len(left)
|
||
end = start + len(strings.TrimRightFunc(left, unicode.IsSpace))
|
||
return start, end
|
||
}
|
||
|
||
func graphRestrictionScopes(content string) []graphRestrictionScope {
|
||
if !graphHasProductDocumentation(content) {
|
||
return nil
|
||
}
|
||
var scopes []graphRestrictionScope
|
||
offset := 0
|
||
for sectionIndex, section := range graphProductSections(content) {
|
||
headings := graphListHeadingPattern.FindAllStringIndex(section, -1)
|
||
for index, heading := range headings {
|
||
label, recognized := graphRestrictionHeadingLabel(section[heading[0]:heading[1]])
|
||
if !recognized {
|
||
continue
|
||
}
|
||
end := len(section)
|
||
if index+1 < len(headings) {
|
||
end = headings[index+1][0]
|
||
}
|
||
body := section[heading[1]:end]
|
||
if boundary := strings.IndexAny(body, "。!?!?"); boundary >= 0 {
|
||
end = heading[1] + boundary
|
||
}
|
||
if boundary := strings.Index(section[heading[1]:end], "\n\n"); boundary >= 0 {
|
||
end = heading[1] + boundary
|
||
}
|
||
start, end := graphTrimSpan(content, offset+heading[0], offset+end)
|
||
scope := graphRestrictionScope{
|
||
start: start, end: end, headingEnd: offset + heading[1],
|
||
section: sectionIndex + 1, ownerText: section[:heading[0]],
|
||
heading: section[heading[0]:heading[1]], label: label, ambiguous: label == "",
|
||
}
|
||
memberStart := scope.headingEnd
|
||
for i, r := range content[scope.headingEnd:scope.end] {
|
||
if !strings.ContainsRune("、,,;;", r) {
|
||
continue
|
||
}
|
||
memberEnd := scope.headingEnd + i
|
||
scope.members = append(scope.members, graphParseRestrictionMember(content, memberStart, memberEnd, label))
|
||
memberStart = memberEnd + len(string(r))
|
||
}
|
||
scope.members = append(scope.members, graphParseRestrictionMember(content, memberStart, scope.end, label))
|
||
for i, member := range scope.members {
|
||
scope.ambiguous = scope.ambiguous || member.ambiguous
|
||
// A suffix predicate after bare coordinated items may govern all of
|
||
// them. Only a heading can supply the omitted predicate here.
|
||
if i > 0 && member.explicit && !scope.members[i-1].explicit {
|
||
scope.ambiguous = true
|
||
}
|
||
if member.target != "" && strings.Count(content[scope.headingEnd:scope.end], member.target) != 1 {
|
||
scope.ambiguous = true
|
||
}
|
||
}
|
||
scopes = append(scopes, scope)
|
||
}
|
||
offset += len(section)
|
||
}
|
||
return scopes
|
||
}
|
||
|
||
func graphParseRestrictionMember(content string, start, end int, headingLabel string) graphRestrictionMember {
|
||
start, end = graphTrimSpan(content, start, end)
|
||
member := graphRestrictionMember{start: start, end: end, label: headingLabel}
|
||
raw := content[start:end]
|
||
predicates := graphListPredicatePattern.FindAllStringIndex(raw, -1)
|
||
if len(predicates) == 0 {
|
||
member.target = raw
|
||
member.sourceTarget = raw
|
||
} else if len(predicates) == 1 {
|
||
predicate := predicates[0]
|
||
member.explicit = true
|
||
member.predicate = raw[predicate[0]:predicate[1]]
|
||
member.label = graphRestrictionLabel(member.predicate)
|
||
before, after := strings.TrimSpace(raw[:predicate[0]]), strings.TrimSpace(raw[predicate[1]:])
|
||
switch {
|
||
case before != "" && after == "":
|
||
member.sourceTarget = before
|
||
member.target = strings.TrimSpace(strings.TrimPrefix(before, "对"))
|
||
case before == "" && after != "":
|
||
member.target = after
|
||
member.sourceTarget = after
|
||
default:
|
||
member.ambiguous = true
|
||
}
|
||
} else {
|
||
member.ambiguous = true
|
||
}
|
||
member.ambiguous = member.ambiguous || member.target == "" || member.label == "" ||
|
||
graphListAmbiguityPattern.MatchString(graphComparisonText(member.target))
|
||
return member
|
||
}
|
||
|
||
func graphRestrictionTypeMatches(expected, actual string) bool {
|
||
return expected == actual || (expected == "禁用于" && actual == "不能用于")
|
||
}
|
||
|
||
func graphRestrictionTargetMatches(member graphRestrictionMember, target string) bool {
|
||
return target != "" && (target == member.target || target == member.sourceTarget)
|
||
}
|
||
|
||
// Display comparisons remove whitespace only, never case or OCR distinctions.
|
||
func graphRestrictionDisplayText(value string) string {
|
||
return strings.Join(strings.Fields(value), "")
|
||
}
|
||
|
||
func graphRestrictionHeadingName(heading string) string {
|
||
return strings.Trim(graphRestrictionDisplayText(heading), "〔〖【[〕〗】]::")
|
||
}
|
||
|
||
func graphRestrictionDisplayParts(value string) (heading, body string, ok bool) {
|
||
value = graphRestrictionDisplayText(value)
|
||
span := graphListHeadingPattern.FindStringIndex(value)
|
||
if span == nil || span[0] != 0 {
|
||
return "", "", false
|
||
}
|
||
heading, body = value[:span[1]], value[span[1]:]
|
||
_, ok = graphRestrictionHeadingLabel(heading)
|
||
return heading, body, ok
|
||
}
|
||
|
||
func graphRestrictionCompleteTargetMatches(scope graphRestrictionScope, target, content string) bool {
|
||
heading, body, ok := graphRestrictionDisplayParts(target)
|
||
if !ok || body == "" || body != graphRestrictionDisplayText(content[scope.headingEnd:scope.end]) {
|
||
return false
|
||
}
|
||
sourceHeading := graphRestrictionDisplayText(scope.heading)
|
||
name := graphRestrictionHeadingName(scope.heading)
|
||
return heading == sourceHeading || heading == name+":" || heading == name+":"
|
||
}
|
||
|
||
func graphRestrictionSubjectMatches(scope graphRestrictionScope, subject string) bool {
|
||
return graphGenericProduct(subject) || (graphProductIdentity(subject) != "" &&
|
||
strings.Contains(graphProductIdentity(scope.ownerText), graphProductIdentity(subject)))
|
||
}
|
||
|
||
func graphRestrictionEvidenceTouches(scope graphRestrictionScope, evidence, content string) bool {
|
||
if evidence == "" {
|
||
return false
|
||
}
|
||
for offset := 0; offset < len(content); {
|
||
index := strings.Index(content[offset:], evidence)
|
||
if index < 0 {
|
||
break
|
||
}
|
||
start := offset + index
|
||
end := start + len(evidence)
|
||
if start < scope.end && end > scope.headingEnd {
|
||
return true
|
||
}
|
||
offset = end
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Independent source/target anchors allow semantic diagnostics even when the
|
||
// model changed its quotation. They never repair that quotation or output edge.
|
||
func graphRestrictionRelation(content string, relation *types.GraphRelation, scopes []graphRestrictionScope) ([]string, bool, error) {
|
||
if relation == nil || len(scopes) == 0 {
|
||
return nil, false, nil
|
||
}
|
||
target := strings.TrimSpace(relation.Node2)
|
||
type match struct {
|
||
scope graphRestrictionScope
|
||
member int
|
||
whole bool
|
||
}
|
||
var candidates []match
|
||
related := false
|
||
for _, scope := range scopes {
|
||
whole := graphRestrictionCompleteTargetMatches(scope, target, content)
|
||
memberIndex := -1
|
||
for i, member := range scope.members {
|
||
if graphRestrictionTargetMatches(member, target) {
|
||
memberIndex = i
|
||
}
|
||
}
|
||
touches := graphRestrictionEvidenceTouches(scope, relation.Evidence, content)
|
||
useLabel := graphPositiveUseLabel(relation.Type) || relation.Type == "慎用于" ||
|
||
relation.Type == "禁用于" || relation.Type == "不适用于" || relation.Type == "不能用于"
|
||
if !whole && memberIndex < 0 && !(touches && (useLabel || relation.Type == "关联" || relation.Type == "约束")) {
|
||
continue
|
||
}
|
||
related = true
|
||
if graphRestrictionSubjectMatches(scope, relation.Node1) {
|
||
candidates = append(candidates, match{scope: scope, member: memberIndex, whole: whole})
|
||
}
|
||
}
|
||
if !related {
|
||
return nil, false, nil
|
||
}
|
||
if len(candidates) > 1 && strings.Contains(content, relation.Evidence) && relation.Evidence != "" {
|
||
var anchored []match
|
||
for _, candidate := range candidates {
|
||
if graphRestrictionEvidenceTouches(candidate.scope, relation.Evidence, content) {
|
||
anchored = append(anchored, candidate)
|
||
}
|
||
}
|
||
candidates = anchored
|
||
}
|
||
if len(candidates) != 1 {
|
||
return nil, true, fmt.Errorf("%w: restriction list has ambiguous target or product scope; preserve a complete literal list constraint with source-specific evidence", ErrGraphNeedsReview)
|
||
}
|
||
candidate := candidates[0]
|
||
scope := candidate.scope
|
||
if candidate.whole && relation.Type == "约束" {
|
||
if !strings.Contains(relation.Evidence, content[scope.start:scope.end]) {
|
||
return nil, true, fmt.Errorf("%w: complete restriction constraint requires unchanged heading and list evidence in source section %d", ErrGraphNeedsReview, scope.section)
|
||
}
|
||
return []string{graphComparisonText(content[scope.start:scope.end])}, true, nil
|
||
}
|
||
if scope.ambiguous || candidate.member < 0 {
|
||
return nil, true, fmt.Errorf("%w: ambiguous restriction list in source section %d requires its complete literal block as a constraint, not an inferred population edge", ErrGraphNeedsReview, scope.section)
|
||
}
|
||
member := scope.members[candidate.member]
|
||
var failures []error
|
||
if !graphRestrictionTypeMatches(member.label, relation.Type) {
|
||
failures = append(failures, fmt.Errorf("%w: restriction strength mismatch in source section %d item %d: expected %s from its own predicate, not %s from a neighboring item", ErrGraphNeedsReview, scope.section, candidate.member+1, member.label, relation.Type))
|
||
}
|
||
claim := content[scope.start:member.end]
|
||
clause := scope.heading + content[member.start:member.end]
|
||
if member.explicit {
|
||
claim = content[member.start:member.end]
|
||
clause = claim
|
||
}
|
||
if !strings.Contains(relation.Evidence, claim) {
|
||
failures = append(failures, fmt.Errorf("%w: restriction evidence must include the unchanged governing predicate and target in source section %d item %d; a heading-governed item needs its heading and intervening text", ErrGraphNeedsReview, scope.section, candidate.member+1))
|
||
}
|
||
return []string{graphComparisonText(clause)}, true, errors.Join(failures...)
|
||
}
|
||
|
||
func validateGraphRestrictionCoverage(graph *types.GraphData, content string) error {
|
||
scopes := graphRestrictionScopes(content)
|
||
var failures []error
|
||
for _, scope := range scopes {
|
||
covered := make([]bool, len(scope.members))
|
||
complete := false
|
||
for _, relation := range graph.Relation {
|
||
if relation == nil || relation.Evidence == "" || !strings.Contains(content, relation.Evidence) ||
|
||
!graphRestrictionEvidenceTouches(scope, relation.Evidence, content) {
|
||
continue
|
||
}
|
||
_, scoped, err := graphRestrictionRelation(content, relation, scopes)
|
||
if !scoped || err != nil || !graphRestrictionSubjectMatches(scope, relation.Node1) {
|
||
continue
|
||
}
|
||
if relation.Type == "约束" && graphRestrictionCompleteTargetMatches(scope, relation.Node2, content) &&
|
||
strings.Contains(relation.Evidence, content[scope.start:scope.end]) {
|
||
complete = true
|
||
break
|
||
}
|
||
for i, member := range scope.members {
|
||
if graphRestrictionTargetMatches(member, strings.TrimSpace(relation.Node2)) && graphRestrictionTypeMatches(member.label, relation.Type) {
|
||
covered[i] = true
|
||
}
|
||
}
|
||
}
|
||
if !complete {
|
||
complete = !scope.ambiguous
|
||
for _, present := range covered {
|
||
complete = complete && present
|
||
}
|
||
}
|
||
if !complete {
|
||
failures = append(failures, fmt.Errorf("%w: restriction list in source section %d is not fully covered; preserve each unambiguous member with its own strength or the complete literal heading and list as one constraint", ErrGraphNeedsReview, scope.section))
|
||
}
|
||
}
|
||
return errors.Join(failures...)
|
||
}
|
||
|
||
func validateGraphRestrictionAttributes(graph *types.GraphData, content string) error {
|
||
scopes := graphRestrictionScopes(content)
|
||
if len(scopes) == 0 {
|
||
return nil
|
||
}
|
||
sectionCount := len(graphProductSections(content))
|
||
var failures []error
|
||
for i, node := range graph.Node {
|
||
if node == nil {
|
||
continue
|
||
}
|
||
_, headingNode := graphRestrictionHeadingLabel(node.Name)
|
||
for j, attribute := range node.Attributes {
|
||
heading, _, labelled := graphRestrictionDisplayParts(attribute)
|
||
if !headingNode && !labelled {
|
||
continue
|
||
}
|
||
name := graphRestrictionHeadingName(heading)
|
||
if headingNode {
|
||
name = graphRestrictionHeadingName(node.Name)
|
||
}
|
||
var candidates []graphRestrictionScope
|
||
for _, scope := range scopes {
|
||
if graphRestrictionHeadingName(scope.heading) != name {
|
||
continue
|
||
}
|
||
// Attributes have no evidence anchor to disambiguate a generic
|
||
// product name across sections or a repeated standalone heading.
|
||
if headingNode || (graphRestrictionSubjectMatches(scope, node.Name) &&
|
||
(!graphGenericProduct(node.Name) || sectionCount == 1)) {
|
||
candidates = append(candidates, scope)
|
||
}
|
||
}
|
||
if len(candidates) != 1 {
|
||
failures = append(failures, fmt.Errorf("%w: entity %d attribute %d has an ambiguous or unsupported restriction product/heading scope; require one literal local source block", ErrGraphNeedsReview, i+1, j+1))
|
||
continue
|
||
}
|
||
scope := candidates[0]
|
||
supported := graphRestrictionCompleteTargetMatches(scope, attribute, content)
|
||
if headingNode && !labelled {
|
||
matches := 0
|
||
for _, member := range scope.members {
|
||
if graphRestrictionDisplayText(attribute) != "" &&
|
||
graphRestrictionDisplayText(attribute) == graphRestrictionDisplayText(content[member.start:member.end]) {
|
||
matches++
|
||
}
|
||
}
|
||
supported = matches == 1
|
||
}
|
||
if !supported {
|
||
failures = append(failures, fmt.Errorf("%w: entity %d attribute %d has unsupported restriction text in source section %d; preserve literal OCR spelling, each local predicate and the complete labelled list", ErrGraphNeedsReview, i+1, j+1, scope.section))
|
||
}
|
||
}
|
||
}
|
||
return errors.Join(failures...)
|
||
}
|