package chatpipeline import ( "context" "encoding/json" "errors" "fmt" "os" "regexp" "sort" "strings" "unicode" "github.com/Tencent/WeKnora/internal/config" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/models/chat" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" ) // PluginExtractEntity is a plugin for extracting entities from user queries // It uses historical dialog context and large language models to identify key entities in the user's original query type PluginExtractEntity struct { modelService interfaces.ModelService // Model service for calling large language models template *types.PromptTemplateStructured // Template for generating prompts knowledgeBaseRepo interfaces.KnowledgeBaseRepository knowledgeService interfaces.KnowledgeService // For shared KB document resolution knowledgeRepo interfaces.KnowledgeRepository } // NewPluginExtractEntity creates a new extract-entity plugin instance // Also registers the plugin with the event manager func NewPluginExtractEntity( eventManager *EventManager, modelService interfaces.ModelService, knowledgeBaseRepo interfaces.KnowledgeBaseRepository, knowledgeService interfaces.KnowledgeService, knowledgeRepo interfaces.KnowledgeRepository, config *config.Config, ) *PluginExtractEntity { res := &PluginExtractEntity{ modelService: modelService, template: config.ExtractManager.ExtractEntity, knowledgeBaseRepo: knowledgeBaseRepo, knowledgeService: knowledgeService, knowledgeRepo: knowledgeRepo, } eventManager.Register(res) return res } // ActivationEvents returns the list of event types this plugin responds to. func (p *PluginExtractEntity) ActivationEvents() []types.EventType { return []types.EventType{types.QUERY_UNDERSTAND} } // OnEvent processes triggered events // When receiving a QUERY_UNDERSTAND event, it extracts entities from the query func (p *PluginExtractEntity) OnEvent(ctx context.Context, eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError, ) *PluginError { legacyGraphEnabled := strings.EqualFold(os.Getenv("LEGACY_GRAPH_ENABLE"), "true") if strings.ToLower(os.Getenv("NEO4J_ENABLE")) != "true" && !legacyGraphEnabled { logger.Debugf(ctx, "skipping extract entity, neo4j is disabled") return next() } query := chatManage.Query // Collect all knowledge base IDs to query kbIDSet := make(map[string]struct{}) for _, id := range chatManage.KnowledgeBaseIDs { kbIDSet[id] = struct{}{} } // If KnowledgeIDs is specified, retrieve them and collect their knowledge base IDs (include shared KB docs) // Also build a mapping from KnowledgeID to KnowledgeBaseID knowledgeToKBMap := make(map[string]string) if len(chatManage.KnowledgeIDs) > 0 { knowledges, err := p.knowledgeService.GetKnowledgeBatchWithSharedAccess(ctx, chatManage.TenantID, chatManage.KnowledgeIDs) if err != nil { logger.Errorf(ctx, "failed to get knowledges: %v", err) return next() } for _, k := range knowledges { kbIDSet[k.KnowledgeBaseID] = struct{}{} knowledgeToKBMap[k.ID] = k.KnowledgeBaseID } } // Convert set to slice allKBIDs := make([]string, 0, len(kbIDSet)) for id := range kbIDSet { allKBIDs = append(allKBIDs, id) } // Batch retrieve all knowledge bases kbs, err := p.knowledgeBaseRepo.GetKnowledgeBaseByIDs(ctx, allKBIDs) if err != nil { logger.Errorf(ctx, "failed to get knowledge bases: %v", err) return next() } // Check if any knowledge base has ExtractConfig enabled and collect their IDs enabledKBSet := make(map[string]struct{}) for _, kb := range kbs { if legacyGraphEnabled || (kb.ExtractConfig != nil && kb.ExtractConfig.Enabled) { enabledKBSet[kb.ID] = struct{}{} } } if len(enabledKBSet) == 0 { logger.Debugf(ctx, "no knowledge base has extract config enabled") return next() } // Save enabled knowledge base IDs for later use in search_entity enabledKBIDs := make([]string, 0, len(enabledKBSet)) for id := range enabledKBSet { enabledKBIDs = append(enabledKBIDs, id) } chatManage.EntityKBIDs = enabledKBIDs // Filter knowledgeToKBMap to only include files from enabled knowledge bases entityKnowledge := make(map[string]string) for knowledgeID, kbID := range knowledgeToKBMap { if _, ok := enabledKBSet[kbID]; ok { entityKnowledge[knowledgeID] = kbID } } chatManage.EntityKnowledge = entityKnowledge // Preserved entities can be matched directly, without re-extracting the corpus. if legacyGraphEnabled { chatManage.Entity = []string{query} return next() } model, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID) if err != nil { logger.Errorf(ctx, "Failed to get model, session_id: %s, error: %v", chatManage.SessionID, err) return next() } template := &types.PromptTemplateStructured{ Description: p.template.Description, Examples: p.template.Examples, } extractor := NewExtractor(model, template) graph, err := extractor.Extract(ctx, query) if err != nil { logger.Errorf(ctx, "Failed to extract entities, session_id: %s, error: %v", chatManage.SessionID, err) return next() } nodes := []string{} for _, node := range graph.Node { nodes = append(nodes, node.Name) } logger.Debugf(ctx, "extracted node: %v", nodes) chatManage.Entity = nodes return next() } // Extractor is a struct for extracting entities type Extractor struct { chat chat.Chat formater *Formater template *types.PromptTemplateStructured chatOpt *chat.ChatOptions requireRelationEvidence bool } // NewExtractor creates a new extractor func NewExtractor( chatModel chat.Chat, template *types.PromptTemplateStructured, ) Extractor { think := false return Extractor{ chat: chatModel, formater: NewFormater(), template: template, chatOpt: &chat.ChatOptions{ Temperature: 0.3, MaxTokens: 4096, Thinking: &think, }, } } // NewGraphExtractor requires source-grounded relations for persisted graphs. // Query entity extraction continues to use NewExtractor without this contract. func NewGraphExtractor(chatModel chat.Chat, template *types.PromptTemplateStructured) Extractor { extractor := NewExtractor(chatModel, template) extractor.requireRelationEvidence = true extractor.chatOpt.Temperature = 0 graphTemplate := *template // Older KB examples do not quote evidence and can teach unsupported labels. graphTemplate.Examples = nil extractor.template = &graphTemplate return extractor } const graphCommonEvidenceInstructions = ` # Source evidence for persisted graph relations Extract the actual subject and relationships of this source. Identify each complete source claim and its predicate before selecting an allowed relation label; a label must not replace or weaken the source predicate. Product-use rules apply only to explicit product usage, never to narrative or personal opinions. Preserve speaker, time and uncertainty. The source may be literature, communication, science or product documentation regardless of the knowledge-base name. 只提取当前原文明确表达的关系,先辨认原文实际领域,再选择允许的标签。标题不是行为主体,事件出现或不出现不等于因果;“怀疑某观点”不等于“不支持”,“包含”只表示真实的组成关系,人物选兵不能写成人物包含士兵。没有更准确的允许标签时,用“关联”保留原文实际表达的关系,但不能把随意共现当关系。 先修条件的方向必须是“先完成的前提 -> 先修条件 -> 后续目标”;例如“要完成实践,首先明确概念”对应“明确概念 -> 先修条件 -> 完成实践”。产品使用步骤应表达为“本品 -> 约束 -> 完整使用步骤”,不能表达为“使用步骤 -> 先修条件 -> 本品”;产品本身不是后续动作或目标。“包含”的方向为整体到组成部分;“交流是会议的内容”对应“会议 -> 包含 -> 交流”。时间前提、操作要求属于“先修条件”或“约束”,不要套成“条件适用于”。约束的目标必须是完整要求或动作,不能只留“使用”“贮存条件”等空泛对象。 观点、可能性、历史与虚构叙事必须保留适用范围。对“以前我认为”“现在我怀疑”“可能”“有时”等表述,优先用带范围的实体和“关联”,不要断言普遍因果或效果。只在 evidence 中保留“可能”仍不足以支持确定的因果边;使用“关联”,或者在关系实体中明确保留该可能性。“导致/不导致/支持/不支持”必须有明确相应语义与正确方向,疑问、愿望、否认事件发生不能冒充因果。“这样”“这些动作”的先行内容必须由当前原文明确给出,引用应覆盖先行内容,否则不抽取该不明关系。 每条关系的 evidence 必须是原文中非空、连续、逐字相同的引文,保留完整条件与否定;原文中的空格、换行、标点及 OCR 字形绝不能修正。例如“不不”在 evidence 中仍须保留“不不”,原文换行在 JSON 字符串中应编码为 \n,不能写成字面量反斜杠加 n。实体名称可简洁清楚,evidence 必须直接复制,不得拼接或从实体名称重写。 数字及单位只能来自各自关系的原文证据。适用、禁用、慎用标签只用于原文明确的使用或适用关系,并保留限制、动作与对象;“不会出现”不是使用禁忌,“不是药品”不是禁用于药品。存储要求是“约束”,不是使用禁忌。 价格属性的类别、金额及包装单位必须来自同一产品的明确语句,或该产品表格行与对应表头。无表头的金额列不能推断为零售价、批发价或促销价;不能借用其他产品、其他价格类别的金额,不能换算或猜测单位。无法确定类别时保留原文信息并交由复核。 返回一个 JSON 数组,数组中只能有以下两种独立对象: {"entity":"实体名称","entity_attributes":["原文支持的简洁属性"]} {"entity1":"实际主体","entity2":"实际对象或完整要求","relation":"允许标签","evidence":"逐字连续原文"} 关系对象只用 entity1/entity2/relation/evidence 四个键,绝不能附加 entity 键,也不能把关系塞入 entity_attributes 来躲避检查。该持久化结构优先于前文笼统的关系附加属性要求,补充的条件与范围保留在完整 evidence 和关系实体中,不添加其他关系字段。证据保留含义,不能为了匹配而缩短到只有两个孤立名称。 Empty or entity-only results require coverage review. 保留全部确有依据的关系;不要捏造关系以避免复核,也不要因为某条有误就丢弃其他正确关系。` const graphRelationEvidenceInstructions = ` # Source evidence for persisted graph relations Extract only relationships explicitly supported by the current input, never by examples or general knowledge. The input can be narrative, science, communication, history, personal opinion, or product documentation. Determine its actual subject from this source, not the knowledge-base name. Extract clear non-product relationships using the allowed labels as well; do not replace all relationships with entity attributes. Product-use rules below apply only to actual usage claims, never to ordinary descriptions or opinions. A statement that an event does not happen is not a usage prohibition. A temporal prerequisite is 先修条件 or 约束, not a population for 条件适用于. 关联 is allowed for an explicit association that has no more precise permitted label; it must still quote the actual association, not merely two unrelated mentions. Preserve the actual subject, direction, speaker, time and uncertainty. A book or section title is not the actor or cause of the events it describes. Resolve "这样", "这些动作", "本症" and similar references only when the current input identifies their antecedent; include that antecedent in the continuous evidence quotation, or omit the unresolved relation. A change of state is not automatically a causal relationship. Do not turn a person's former opinion into a current or universal fact: keep "以前", "现在", "我认为", named speakers and other attribution in evidence and scoped entity names. For disputed, quoted or uncertain claims, use 关联 with that scope, rather than asserting unqualified 导致 or 支持. Preserve "可能", "有时", "通常" and other qualifiers; do not convert possibility into certainty or a fictional event into general advice. Extract only what the text says, not whether its medical or other advice is correct. Every relation object MUST include "evidence": an exact, nonempty, contiguous quotation from the current input. Preserve its original whitespace, punctuation and OCR spelling. Quote the complete proposition including any negation, warning, condition or exception; never omit these words from the quotation. Copy evidence directly from the input rather than reconstructing it from entity names or attributes. In particular, do not remove spaces around Chinese punctuation, fix OCR characters, substitute similar brackets, or join disjoint passages. Prefer the shortest complete clause that establishes the relationship. Omit a heading only when the quoted clause independently states the complete predicate, polarity and scope. A heading that governs a list supplies its predicate: quote that heading and all intervening text through the target continuously. 对 evidence 必须逐字复制,空格、错别字、〔 和 〖 等不同括号都不能改写。 For labelled restriction lists, distinguish the heading predicate from each item's explicit local predicate. A caution stated for one item does not make neighboring bare items cautious; bare items retain the heading's restriction. 不适宜/不适用 is 不适用于, 慎用 is 慎用于, and 禁用 is 禁用于; never weaken or strengthen one into another. Preserve literal OCR spelling in restriction targets, entity names and attributes. If coordination, a repeated target or a damaged predicate makes the scope ambiguous, use the local product -> 约束 -> complete heading and list as one target, with the complete unchanged block as evidence, or leave the source for review. Only the complete display target or labelled attribute may replace the source heading brackets with : or : and vary whitespace; retain every other character, list delimiter, member order and local predicate. Evidence must stay verbatim. Do not guess typed population edges. Preserve every list member; attributes alone or an unqualified 关联 to a population cannot replace its restriction. Preserve supported usage procedures as complete product -> 约束 relations, including during correction; usage attributes alone do not replace those relations. Preserve polarity and conditions in the relation label. "过敏者慎用" requires "慎用于", "粘膜破损处禁用" requires "禁用于", and "不得用于性生活中对性病的预防" requires "不适用于" or "禁用于", never "适用于". A conditional product suitability or efficacy claim requires "条件适用于" or "约束", never unconditional suitability or efficacy. Do not infer a disease-prevention, treatment, nursing or health effect from a disclaimer saying the product has no such effect. "条件适用于" means use is permitted under a stated condition; it must never describe a prohibition that applies under a condition. "避开", "勿用", "请勿" and "停止使用" express prohibitions, not caution or conditional permission. "如有不适,请立即停止使用" requires a stop-use constraint, not "慎用于". Preserve the scope of each restriction in the entity names: "哺乳期请勿在乳头使用" can be product -> 禁用于 -> 哺乳期乳头使用, not product -> 禁用于 -> 哺乳期; "用于阴道时,避开产期" must retain both 产期 and 阴道使用. If an OCR-corrupted condition cannot be interpreted reliably, omit it rather than inventing or reversing its meaning. "避免接触眼睛" is a contact prohibition, but a benefit such as "避免不适" is not a prohibition. "若不慎入眼,请立即用清水冲洗" describes an accident response, never permission to use the product on eyes: use product -> 约束 -> 不慎入眼后立即用清水冲洗 with the exact source quote, or omit the relation. Do not turn an accident, symptom, or remedial action into a suitable population or use site. "条件适用于" requires both an explicit condition and an explicit use purpose or population in the source; keep the complete condition in evidence and the actual use purpose or population in entity2. A condition such as "无过敏现象再使用" is not permission to use a product on "过敏现象". A bare condition such as "无过敏现象再使用" is itself a 约束 target, never a 条件适用于 target; when no use purpose or population is named in that source clause, use 约束. General causal or supporting statements can remain 导致, 不导致, 支持 or 不支持 with their original conditions and negations in evidence; do not convert them into product suitability. Preserve restriction operations: "不得与阴离子成分同时使用" and "避免与拮抗药物同用" restrict combined use, not the other ingredient or drug in isolation; 同用、合用、混用、联用 and 同时使用 all describe that operation. "不得代替药品使用" restricts "代替药品使用", not 药品 in isolation. "本品不是药品" is an identity disclaimer, not product -> 不适用于 -> 药品; preserve it as a source-supported entity attribute. Separate effect disclaimers such as "不具有治疗作用" may support 不支持 -> 治疗作用, never a blanket prohibition on 药品. Suppression and support are opposites. "对白色念珠菌有抑制作用" must never become product -> 支持 -> 白色念珠菌. Use 抑制 when allowed, otherwise 关联 with the exact suppression quote, or omit the relation. "告别冷淡", "改善干燥" and similar benefit claims do not establish a usage prohibition or denial of an effect: never emit 禁用于, 不适用于 or 不支持 from a benefit claim. These labels require explicit source restrictions or denials. When a benefit has no precise allowed label, use 关联 with its exact quote or omit it. Attribute each relation to the specifically named product when the current source identifies it; avoid generic 本品 nodes that conflate separate products in a multi-product document. For all suitability/use relations (including 慎用于, 禁用于, 不适用于 and 条件适用于), entity1 MUST be the product, action or tool being used, and entity2 MUST be the population, body site or purpose of that use. Thus "过敏者慎用" means product -> 慎用于 -> 过敏者, never 过敏者 -> 慎用于 -> product. A disclaimer that a product has no claimed effect can be product -> 不支持 -> claimed effect, with the complete disclaimer as evidence. Positive suitability labels require an explicit use instruction or suitability claim in the source. Do not infer suitability from a benefit, product slogan, ingredient, or an uncertain OCR fragment. Storage requirements (贮存、保存、存放、阴凉干燥处) are 约束, never 适用于 or 条件适用于. 抑制 requires an explicitly suppressed target; a product advertised as 抑菌 supports antibacterial activity, so product -> 抑制 -> 抑菌 reverses the claim and must not be emitted. Preserve every numeric value and unit in entity names and attributes exactly as stated. A target containing a quantity must use the quantity in its own evidence: 富含3350ppm多肽 cannot support 6650ppm多肽, even if another passage has that number. Do not calculate, combine, convert or guess concentrations, counts or amounts. Respect product boundaries. Table rows and 产品名称 sections describe their own products; use the specific product name, not the shared brand, for ingredients and claims. An unnamed fragment before a blank line and the next product heading belongs to an unresolved preceding product, never to the later named product. Use 本品 for an unresolved local referent instead of guessing a later product's name. Do not copy attributes or relations between products, across table rows, or across separated product sections. Use the most precise allowed negative or conditional label. If no available label faithfully expresses the source, omit that unsupported relation. Do not invent, reverse or weaken a restriction to fit a positive label. Return one JSON array containing two distinct kinds of object. Entity objects have exactly these keys: {"entity":"entity name","entity_attributes":["source-supported attribute"]}. Relation objects have exactly these keys: {"entity1":"source entity","entity2":"target entity","relation":"accurate label","evidence":"exact quotation from this input"}. Never use "entity" as a relation's source key; use "entity1". Never mix entity and relation keys in one object. Emit each entity once, merge its attributes without repetition, then emit each relation once. Keep attributes concise and do not duplicate them as additional entity objects. Before returning, verify every relation has entity1, entity2, relation and evidence, and every evidence string is an unchanged substring of the current input. Return [] only when the input contains no extractable entities or relations. Empty or entity-only results require coverage review and cannot automatically complete graph extraction. Never invent a relationship to avoid that review; preserve all genuinely supported relationships instead of dropping them to escape a validation error.` // ErrGraphNeedsReview prevents incomplete coverage or ambiguous source claims // from being persisted as completed graph extraction without source review. var ErrGraphNeedsReview = errors.New("graph_needs_review") // Extract extracts entities from content func (e *Extractor) Extract(ctx context.Context, content string) (*types.GraphData, error) { generator := NewQAPromptGenerator(e.formater, e.template) // logger.Debugf(ctx, "chat system: %s", generator.System(ctx)) // logger.Debugf(ctx, "chat user: %s", generator.User(ctx, content)) modelCtx := types.WithLLMCallMetadata(ctx, "entity_extraction", "") messages := generator.Render(ctx, content) sourcePrompt := content if e.requireRelationEvidence { messages[0].Content += graphCommonEvidenceInstructions if graphHasProductDocumentation(content) { messages[0].Content += graphRelationEvidenceInstructions } messages[0].Content += graphProductScopeInstructions(content) sourcePrompt = graphProductSourcePrompt(content) messages[1].Content = generator.User(ctx, sourcePrompt) } for attempt := 0; ; attempt++ { chatResponse, err := e.chat.Chat(modelCtx, messages, e.chatOpt) if err != nil { logger.Errorf(ctx, "failed to chat: %v", err) return nil, err } graph, err := e.parseChatResponse(ctx, content, chatResponse) if err == nil { return graph, nil } // Give a completed graph one bounded correction with the precise validation // failure. Every corrected relation still passes the same source checks. if !e.requireRelationEvidence || attempt > 0 || chatResponse == nil || chatResponse.FinishReason == "length" || strings.TrimSpace(chatResponse.Content) == "" { return nil, err } messages = append(messages, chat.Message{Role: "assistant", Content: chatResponse.Content}, chat.Message{Role: "user", Content: "Your graph failed validation:\n" + err.Error() + "\nReturn the complete corrected JSON array for the same original input. Correct every reported error and preserve the other supported relations. Do not reuse rewritten quotations from your previous output. 逐字复制下面原文的连续 evidence,保留空格、换行和 OCR;关系对象必须仅含 entity1/entity2/relation/evidence。保持实际主体、方向、否定、动作与条件;不确定观点和叙事优先使用有范围的关联,不能套用产品适用标签。名单的标题若提供限制谓词,evidence 必须连续包含标题到该对象的原文;某项的慎用不得传给其他项,不适宜不得变成慎用或禁用。保留各项原文限制;范围含混时只能由你明确输出以完整标题和名单为目标的约束及其逐字 evidence,不能猜测或删掉名单。完整名单显示目标及带标题属性只能将原文标题括号换为冒号并调整空白,名单正文字符、分隔符、顺序和局部谓词均须保留;evidence 仍逐字不变。限制实体名称和属性也必须保留原文 OCR 字形,不得猜字补字。约束目标保留完整动作:避免与其他产品同用、如有不适立即停止使用、避光密封保存等,不能缩成同用/使用/阳光。保留有据的用量及使用步骤约束关系,例如每日1瓶、开瓶即饮,不能仅放进属性而删掉关系。不同产品分节的本品不能合并,后文具名产品不得接收前文的警告。修复能修复的有据关系,不得捏造,也不得靠返回空图躲避校验。\n\nOriginal source (unchanged):\n" + sourcePrompt}, ) } } func (e *Extractor) parseChatResponse(ctx context.Context, content string, chatResponse *types.ChatResponse) (*types.GraphData, error) { if chatResponse == nil { return nil, errors.New("entity extraction returned no response") } if chatResponse.FinishReason == "length" { return nil, fmt.Errorf("entity extraction exceeded the output token limit (max_tokens=%d, completion_tokens=%d)", e.chatOpt.MaxTokens, chatResponse.Usage.CompletionTokens) } if strings.TrimSpace(chatResponse.Content) == "" { return nil, fmt.Errorf("entity extraction returned empty content (finish_reason=%q, completion_tokens=%d)", chatResponse.FinishReason, chatResponse.Usage.CompletionTokens) } var sourceContent *string if e.requireRelationEvidence { sourceContent = &content } graph, err := e.formater.parseGraph(ctx, chatResponse.Content, sourceContent) if err != nil { logger.Errorf(ctx, "failed to parse graph: %v", err) return nil, err } if e.requireRelationEvidence && len(e.template.Tags) > 0 { allowed := make(map[string]bool, len(e.template.Tags)) for _, tag := range e.template.Tags { allowed[tag] = true } for i, relation := range graph.Relation { if !allowed[relation.Type] { return nil, fmt.Errorf("graph relation %d uses a label outside the configured relation types", i+1) } } } if e.requireRelationEvidence && len(graph.Relation) == 0 { if len(graph.Node) == 0 { return nil, fmt.Errorf("%w: no extracted content; return supported relations if present, otherwise leave this source for review", ErrGraphNeedsReview) } return nil, fmt.Errorf("%w: entities without source-grounded relations; verify relationship coverage without inventing edges", ErrGraphNeedsReview) } return graph, nil } // RemoveUnknownRelation removes unknown relations from graph func (e *Extractor) RemoveUnknownRelation(ctx context.Context, graph *types.GraphData) { relationType := make(map[string]bool) for _, tag := range e.template.Tags { relationType[tag] = true } relationNew := make([]*types.GraphRelation, 0) for _, relation := range graph.Relation { if _, ok := relationType[relation.Type]; ok { relationNew = append(relationNew, relation) } else { logger.Infof(ctx, "Unknown relation type %s with %v, ignore it", relation.Type, e.template.Tags) } } graph.Relation = relationNew } var graphProhibitionPattern = regexp.MustCompile(`禁|不得|不能|不可|不宜|不应|不建议|不适宜|不适用|不适合|不用于|不具有|不具备|没有.{0,8}(作用|功效)|无.{0,8}(作用|功效)|避开|避免接触|请勿|勿用|停用|停止使用`) var graphCautionPattern = regexp.MustCompile(`慎用|慎甩|谨慎使用`) var graphConditionPattern = regexp.MustCompile(`仅供|仅限|仅在|只限|只有|如果|如有|除非|前提|才可|方可|须在|需在|若|当.+时|(?:清洗|清洁|试用|检查|确认|完成|取得|获得|满足)[^,。;,;]{0,30}后|无[^,。;,;]{1,16}再(?:使用|服用)`) var graphClausePattern = regexp.MustCompile(`[,,]|但是|然而|但`) var graphIndependentClaimPattern = regexp.MustCompile(`适用|用于|可以|可用|具有|具备|改善|促进|缓解|增强|治疗|预防|禁用|慎用|慎甩|停用|停止使用|支持|导致|抑制`) var graphSectionHeadingPattern = regexp.MustCompile(`[〔〖【\[]\s*(使用范围|使用方法|注意事项|警示说明|贮藏条件|贮存方法|储存方法|储藏条件|储存条件|抑制微生物类别)\s*[〕〗】\]]`) var graphNumberedItemPattern = regexp.MustCompile(`(^|\s)[1-9][0-9]?\s*[..]\s+`) var graphStopUseContinuationPattern = regexp.MustCompile(`^(请|应|需|须|必须)?(立即|马上)?(停用|停止使用)`) var graphSuppressionPattern = regexp.MustCompile(`抑制|杀灭|杀菌|抑菌|抗菌`) var graphMicroorganismPattern = regexp.MustCompile(`(菌|病毒|微生物)$`) var graphRestrictedSitePattern = regexp.MustCompile(`(请勿|不得|禁止|不可)在([^,。;,;]{1,20})(使用|涂抹|涂用)`) var graphRestrictedUseScopePattern = regexp.MustCompile(`用于([^,。;,;]{1,12})时`) var graphRestrictedCombinedUsePattern = regexp.MustCompile(`(?:与|和)([^,。;,;]{1,30}?)(?:(?:同时|共同|一起|一同)(?:使用|服用)|合用|混用|联用|同用)`) var graphAvoidCombinedUsePattern = regexp.MustCompile(`避免(?:与|和)[^,。;,;]{1,30}(?:(?:同时|共同|一起|一同)(?:使用|服用)|合用|混用|联用|同用)`) var graphCombinedUseScopePattern = regexp.MustCompile(`(?:同时|共同|一起|一同)(?:使用|服用)|合用|混用|联用|同用`) var graphRestrictedSubstitutionPattern = regexp.MustCompile(`(?:代替|替代)([^,。;,;]{1,20}?)(?:使用|$)`) var graphSubstitutionScopePattern = regexp.MustCompile(`代替|替代`) var graphDeniedSupportPattern = regexp.MustCompile(`不(?:能|会|再)?(?:支持|具有|具备)|没有.{0,8}(作用|功效)|无.{0,8}(作用|功效)`) var graphDeniedCausationPattern = regexp.MustCompile(`不(?:会|能)?导致`) var graphQuantityPattern = regexp.MustCompile(`[0-9]+(?:[..·][0-9]+)*(?:ppm|ppb|kg|mg|ug|ml|cm|mm|min|g|l|%|%|毫克|千克|克|毫升|升|分钟|小时|天|颗|粒|瓶|支|片)?`) var graphStoragePattern = regexp.MustCompile(`贮存|贮藏|储存|储藏|保存|存放`) var graphStorageLocationPattern = regexp.MustCompile(`阴凉|干燥处|冷藏|常温|避光|遮光|密封`) var graphUseClaimPattern = regexp.MustCompile(`适用|适合|用于|可用|使用|涂于|涂抹|推入|推进|擦拭|清洁|饮用|服用|外用|内服|口服`) var graphUseTargetPattern = regexp.MustCompile(`(?:适用于|适合|用于|涂抹于|涂于|推入|推进|擦拭|清洁)([^,。;,;]{1,60})|(?:仅供|仅限)(外用|内服|口服)`) var graphProductHeadingPattern = regexp.MustCompile(`产品名称|品名`) var graphProductNameMarkerPattern = regexp.MustCompile(`产品\s*名称`) var graphNumberedProductHeadingPattern = regexp.MustCompile(`(?m)^[ \t]*(?:#{1,6}[ \t]*)?[0-9]{1,3}[ \t]*(?:丨|\|)[^\n]+`) var graphStopUsePattern = regexp.MustCompile(`停止使用|停用`) var graphStopUseConditionPattern = regexp.MustCompile(`(?:如有|如果|若)([^,。;,;]{1,24})`) var graphImmediatePattern = regexp.MustCompile(`立即|马上`) var graphStorageActionPattern = regexp.MustCompile(`贮存|贮藏|储存|储藏|保存|存放|置于|放在|避光|遮光|密封|防潮|避免.*(?:直晒|直射)|不易触及|远离儿童`) var graphIdentityDisclaimerPattern = regexp.MustCompile(`不是([^,。;,;]{1,20})`) var graphClaimQualifierPattern = regexp.MustCompile(`可能|也许|或许|有时|往往|通常|曾经|以前|过去|目前|现在|暂时|将来|我认为|我觉得|我坚持|我怀疑`) var graphPossibilityPattern = regexp.MustCompile(`可能|也许|或许|有时`) var graphOpinionPattern = regexp.MustCompile(`(?:我|作者|叙述者)(?:曾经|以前|现在)?(?:认为|觉得|坚持|怀疑)`) var graphOpinionScopePattern = regexp.MustCompile(`观点|看法|认为|觉得|坚持|怀疑`) // Normalize only known compatibility-glyph OCR duplicates for comparison. // Evidence is always validated and returned against the unchanged source. var graphOCRComparisonReplacer = strings.NewReplacer("不不", "不", "不", "不") func graphComparisonText(value string) string { return graphOCRComparisonReplacer.Replace(compactGraphText(value)) } func graphHasProhibition(value string) bool { return graphProhibitionPattern.MatchString(value) || graphAvoidCombinedUsePattern.MatchString(value) } func graphGeneralClaim(label string) bool { return label == "导致" || label == "不导致" || label == "支持" || label == "不支持" } func graphReversesExplicitDirection(relation *types.GraphRelation) bool { from := regexp.QuoteMeta(graphComparisonText(relation.Node1)) to := regexp.QuoteMeta(graphComparisonText(relation.Node2)) quote := graphComparisonText(relation.Evidence) var pattern string switch relation.Type { case "包含": pattern = from + `(?:是|属于)` + to + `[^。;!?!?]{0,18}(?:内容|组成部分|一部分|成员)|` + to + `(?:包含|包括)` + from case "先修条件": pattern = to + `是` + from + `的(?:先修条件|前提)` default: return false } return regexp.MustCompile(pattern).MatchString(quote) } func graphBorrowsHeadingSubject(content string, relation *types.GraphRelation) bool { name := graphComparisonText(relation.Node1) if strings.Contains(graphComparisonText(relation.Evidence), name) { return false } for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") && graphComparisonText(strings.TrimLeft(line, "#")) == name { return true } } return false } func graphBorrowsProductSectionSubject(content string, relation *types.GraphRelation) bool { if relation.Type != "约束" { return false } name := graphComparisonText(relation.Node1) // A named concept in the body can share a label with its section heading. bodyEvidence := graphSectionHeadingPattern.ReplaceAllString(relation.Evidence, "") if strings.Contains(graphComparisonText(bodyEvidence), name) { return false } for _, heading := range graphSectionHeadingPattern.FindAllStringSubmatch(content, -1) { if graphComparisonText(heading[1]) == name { return true } } return false } func graphQuantities(value string) map[string]bool { quantities := make(map[string]bool) for _, quantity := range graphQuantityPattern.FindAllString(compactGraphText(value), -1) { quantity = strings.NewReplacer(".", ".", "·", ".", "%", "%").Replace(quantity) quantities[quantity] = true } return quantities } func graphEndpointQuantitiesGrounded(endpoint, source string) bool { available := graphQuantities(source) for quantity := range graphQuantities(endpoint) { if !available[quantity] { return false } } return true } func graphPositiveUseLabel(label string) bool { switch label { case "适用于", "适合", "用于", "可用于", "条件适用于": return true default: return false } } // A trailing fragment from one product must not be assigned to the next named // product simply because only that later product has a name in this chunk. func graphProductIdentity(value string) string { return strings.ToLower(strings.Map(func(r rune) rune { if unicode.IsLetter(r) || unicode.IsDigit(r) { return r } return -1 }, value)) } func graphGenericProduct(value string) bool { value = graphComparisonText(value) switch value { case "本品", "该产品", "本产品", "产品", "本商品": return true } return strings.HasSuffix(value, "未名产品") || strings.HasPrefix(value, "本品(") } // Product markers define local attribution scopes even when OCR spaces names // or Markdown separates a brand and product name into adjacent columns. func graphProductSections(content string) []string { starts := map[int]bool{0: true} for _, span := range graphProductNameMarkerPattern.FindAllStringIndex(content, -1) { start := strings.LastIndex(content[:span[0]], "\n\n") if start < 0 { start = 0 } else { start += 2 } starts[start] = true } for _, span := range graphNumberedProductHeadingPattern.FindAllStringIndex(content, -1) { starts[span[0]] = true } productColumn, offset := -1, 0 for _, line := range strings.SplitAfter(content, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "|") { columns := strings.Split(strings.Trim(trimmed, "|"), "|") isHeader := false for i, column := range columns { if graphComparisonText(column) == "产品名称" { productColumn, isHeader = i, true break } } if !isHeader && productColumn >= 0 && productColumn < len(columns) { name := strings.TrimSpace(columns[productColumn]) if name != "" && strings.Trim(name, "-: ") != "" { starts[offset] = true } } } else if trimmed != "" { productColumn = -1 } offset += len(line) } ordered := make([]int, 0, len(starts)+1) for start := range starts { ordered = append(ordered, start) } sort.Ints(ordered) ordered = append(ordered, len(content)) sections := make([]string, 0, len(ordered)-1) for i := 0; i+1 < len(ordered); i++ { sections = append(sections, content[ordered[i]:ordered[i+1]]) } return sections } func graphHasProductDocumentation(content string) bool { text := graphComparisonText(content) return graphProductHeadingPattern.MatchString(text) || strings.Contains(text, "本品") || strings.Contains(text, "产品成分") || strings.Contains(text, "产品功效") } func graphProductScopeInstructions(content string) string { const series = "\n系列介绍、套盒目录等标题不证明下面每条规则都适用于整个系列。遇到 OCR 混排且无法明确具体产品时,局部警告使用本品,不猜测系列名或产品名;保留每条可辨认的慎用、禁用、不得用于及存储要求,不能因为身份不明就漏掉这些规则。" if len(graphProductSections(content)) < 2 { if graphHasProductDocumentation(content) { return series } return "" } return series + "\n输入 source_sections 是按原始顺序切开的原文,不是新增事实。将各段 text 原样连接就是完整原文;evidence 从解码后的 text 逐字复制,不能包含 JSON 字段或 section 编号。逐段抽取后合并结果,每条关系先确定它所在的 section。表头只解释其后的产品行,不能把产品名称向前套用到另一段。表头段没有独立关系不必编造;具体产品行可以结合紧前的表头解释列。前段只有警告和贮存要求而没有产品名称时,主体使用本品;后段有名称时必须使用该具体名称,不能继续复用同一个本品实体。若多个产品都无法指明名称,使用前段未名产品、后段未名产品等独立局部名称。不能为了通过校验而删掉前段警告、贮存、后段属性或明确关系。表格品牌与产品名称只可来自同一行。重复警告要引用所在产品的足够上下文以区分归属。约束保留完整动作和否定。" } // Source sections contain unchanged slices, never inferred product identities. // Their concatenation remains the only source used by all evidence validators. func graphProductSourcePrompt(content string) string { sections := graphProductSections(content) if len(sections) < 2 { return content } type section struct { Number int `json:"section"` Text string `json:"text"` } parts := make([]section, 0, len(sections)) for i, text := range sections { parts = append(parts, section{Number: i + 1, Text: text}) } encoded, _ := json.Marshal(struct { Sections []section `json:"source_sections"` }{Sections: parts}) return string(encoded) } func graphProductSectionNumbers(content, value string, identity bool) []int { var numbers []int for i, section := range graphProductSections(content) { if identity { section = graphProductIdentity(section) value = graphProductIdentity(value) } if value != "" && strings.Contains(section, value) { numbers = append(numbers, i+1) } } return numbers } func graphBorrowsSeriesSubject(content string, relation *types.GraphRelation) bool { if relation == nil || graphGenericProduct(relation.Node1) { return false } name := graphProductIdentity(relation.Node1) if !strings.HasSuffix(name, "系列") && !strings.HasSuffix(name, "套盒") { return false } lines := strings.Split(content, "\n") headingFound, bodyFound, firstLine := false, false, true for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } isHeading := firstLine || strings.HasPrefix(line, "#") firstLine = false identity := graphProductIdentity(line) if isHeading && strings.TrimSuffix(identity, "介绍") == name { headingFound = true continue } bodyFound = bodyFound || strings.Contains(identity, name) } return headingFound && !bodyFound } func graphBorrowsLaterProduct(content string, relation *types.GraphRelation) bool { if graphGenericProduct(relation.Node1) { return false } name := graphProductIdentity(relation.Node1) if name == "" { return false } nameFound, sourceFound := false, false for _, section := range graphProductSections(content) { if strings.Contains(graphProductIdentity(section), name) { nameFound = true if strings.Contains(section, relation.Evidence) { return false } } sourceFound = sourceFound || strings.Contains(section, relation.Evidence) } return nameFound && sourceFound } func validateGraphProductReferents(graph *types.GraphData, content string) error { sections := graphProductSections(content) if len(sections) < 2 { return nil } referents := make(map[string]int) for _, relation := range graph.Relation { if relation == nil || !graphGenericProduct(relation.Node1) || relation.Evidence == "" { continue } scope := -1 for i, section := range sections { if strings.Contains(section, relation.Evidence) { if scope >= 0 { return errors.New("graph uses an ambiguous product pronoun for evidence repeated across product sections; use the named product and section-specific evidence") } scope = i } } name := graphComparisonText(relation.Node1) if previous, ok := referents[name]; ok && scope >= 0 && previous != scope { return errors.New("graph merges an unnamed product pronoun across product sections; keep the leading unnamed product separate and name later products explicitly") } if scope >= 0 { referents[name] = scope } } return nil } func graphAffirmativeClaim(relation string) bool { switch strings.TrimSpace(relation) { case "适用于", "适合", "用于", "可用于", "作用于", "支持", "抑制", "改善", "增强", "促进", "缓解", "治疗", "预防", "护理", "保健", "具有", "具备", "有助于", "实现", "产生", "导致", "带来", "解决", "功效", "效果": return true default: return false } } func graphUseOrEfficacyLabel(label string) bool { if graphPositiveUseLabel(label) { return true } switch label { case "作用于", "抑制", "改善", "增强", "促进", "缓解", "治疗", "预防", "护理", "保健", "具有", "具备", "有助于", "功效", "效果": return true default: return false } } func graphConditionalUseTargetGrounded(quote, target string) bool { for _, match := range graphUseTargetPattern.FindAllStringSubmatch(quote, -1) { for _, useTarget := range match[1:] { if strings.Contains(useTarget, target) { return true } } } return false } func compactGraphText(value string) string { return strings.ToLower(strings.Map(func(r rune) rune { if unicode.IsSpace(r) { return -1 } return r }, value)) } // Inspect the complete source sentence, not just the quote, so a model cannot // turn "不得用于..." into a positive claim by quoting only "用于...". func graphEvidenceContexts(content, evidence string) []string { const boundaries = "。!?!?;;\n\r" var contexts []string for offset := 0; offset < len(content); { index := strings.Index(content[offset:], evidence) if index < 0 { break } start, end := offset+index, offset+index+len(evidence) left := strings.LastIndexAny(content[:start], boundaries) if left < 0 { left = 0 } right := strings.IndexAny(content[end:], boundaries) if right < 0 { right = len(content) } else { right += end } contexts = append(contexts, content[left:right]) offset = end } return contexts } func graphRelationClaimClauses(context string, relation *types.GraphRelation) []string { var relevant []string // OCR can concatenate separate labelled sections without sentence punctuation. context = graphSectionHeadingPattern.ReplaceAllString(context, "。") context = graphNumberedItemPattern.ReplaceAllString(context, "。") for _, sentence := range strings.FieldsFunc(context, func(r rune) bool { return strings.ContainsRune("。!?!?;;\n\r", r) }) { clauses := graphClausePattern.Split(graphComparisonText(sentence), -1) focus := graphComparisonText(relation.Node2) switch focus { case "本品", "该产品", "本产品", "产品": focus = graphComparisonText(relation.Node1) } for i, clause := range clauses { if !strings.Contains(clause, focus) { continue } // A condition can precede a comma, and a negated list can continue // without repeating its predicate. Do not borrow an unrelated warning. for j := i - 1; j >= 0; j-- { prior := clauses[j] if graphIndependentClaimPattern.MatchString(clause) && ((!graphConditionPattern.MatchString(prior) && !graphOpinionPattern.MatchString(prior) && !graphClaimQualifierPattern.MatchString(prior)) || graphIndependentClaimPattern.MatchString(prior)) { break } clause = prior + "," + clause } if i+1 < len(clauses) && graphStopUseContinuationPattern.MatchString(clauses[i+1]) { clause += "," + clauses[i+1] } relevant = append(relevant, clause) } } return relevant } func validateGraphRelationEvidence(graph *types.GraphData, content string) error { var failures []error if err := validateGraphProductReferents(graph, content); err != nil { failures = append(failures, err) } for i, relation := range graph.Relation { if err := validateGraphRelationEvidenceItem(&types.GraphData{Relation: []*types.GraphRelation{relation}}, content); err != nil { failures = append(failures, fmt.Errorf("relation object %d: %w", i+1, err)) } if graphBorrowsSeriesSubject(content, relation) { failures = append(failures, fmt.Errorf("relation object %d: product series heading does not identify the subject of this local rule; preserve the rule with a local 本品 referent when the specific product is unresolved", i+1)) } if len(failures) >= 8 { break } } return errors.Join(failures...) } func validateGraphRelationEvidenceItem(graph *types.GraphData, content string) error { restrictionScopes := graphRestrictionScopes(content) for i, relation := range graph.Relation { if relation == nil || strings.TrimSpace(relation.Node1) == "" || strings.TrimSpace(relation.Node2) == "" || strings.TrimSpace(relation.Type) == "" || relation.Type == "" || relation.Node1 == relation.Node2 { return fmt.Errorf("graph relation %d is invalid", i+1) } restrictionClauses, scopedRestriction, restrictionErr := graphRestrictionRelation(content, relation, restrictionScopes) if strings.TrimSpace(relation.Evidence) == "" || !strings.Contains(content, relation.Evidence) { return errors.Join(fmt.Errorf("graph relation %d requires exact evidence from the current source chunk", i+1), restrictionErr) } if restrictionErr != nil { return restrictionErr } if !graphEndpointQuantitiesGrounded(relation.Node1, content) || !graphEndpointQuantitiesGrounded(relation.Node2, relation.Evidence) { return fmt.Errorf("graph relation %d has an endpoint quantity absent from its source evidence", i+1) } if graphBorrowsLaterProduct(content, relation) { return fmt.Errorf("graph relation %d assigns an earlier unnamed product fragment to a later named product: evidence is in source sections %v; proposed subject occurs in sections %v. Resolve the subject within the evidence section; if unnamed use a local 本品 without borrowing the later name. Keep the supported warning or storage relation", i+1, graphProductSectionNumbers(content, relation.Evidence, false), graphProductSectionNumbers(content, relation.Node1, true)) } if graphReversesExplicitDirection(relation) { return fmt.Errorf("graph relation %d reverses the explicitly stated relation direction; 包含 runs from whole to part and 先修条件 from prerequisite to subsequent goal", i+1) } if relation.Type == "先修条件" && graphGenericProduct(relation.Node2) { return fmt.Errorf("graph relation %d uses a product itself as a prerequisite goal; product usage procedures belong to product -> 约束 -> complete usage action, while 先修条件 must target a subsequent action or goal", i+1) } if graphBorrowsProductSectionSubject(content, relation) { return fmt.Errorf("graph relation %d uses a product section heading as the owner of a product rule; preserve the complete rule with the actual local product as entity1, or 本品 when unnamed", i+1) } if graphGeneralClaim(relation.Type) && graphBorrowsHeadingSubject(content, relation) { return fmt.Errorf("graph relation %d uses a source heading as an ungrounded causal or supporting subject; resolve the actual source actor", i+1) } var clauses []string contexts := graphEvidenceContexts(content, relation.Evidence) for _, context := range contexts { clauses = append(clauses, graphRelationClaimClauses(context, relation)...) } if scopedRestriction { clauses = restrictionClauses } // An alias may not match literally. A restricted quotation must still // never support an unconditional affirmative claim in that case. if len(clauses) == 0 { for _, context := range contexts { clauses = append(clauses, graphComparisonText(context)) } } negativeUse := relation.Type == "禁用于" || relation.Type == "不适用于" || relation.Type == "不能用于" caution := relation.Type == "慎用于" conditionalUse := relation.Type == "条件适用于" restrictionFound := false deniedSupport, deniedCausation := false, false for _, clause := range clauses { prohibition := graphHasProhibition(clause) restricted := prohibition || graphCautionPattern.MatchString(clause) restrictionFound = restrictionFound || restricted deniedSupport = deniedSupport || graphDeniedSupportPattern.MatchString(clause) deniedCausation = deniedCausation || graphDeniedCausationPattern.MatchString(clause) if (graphAffirmativeClaim(relation.Type) || conditionalUse) && (restricted || graphDeniedSupportPattern.MatchString(clause) || graphDeniedCausationPattern.MatchString(clause)) { return fmt.Errorf("graph relation %d has an affirmative label that conflicts with a source restriction", i+1) } if caution && prohibition { return fmt.Errorf("graph relation %d weakens a source prohibition to caution", i+1) } if graphUseOrEfficacyLabel(relation.Type) && !conditionalUse && graphConditionPattern.MatchString(clause) { return fmt.Errorf("graph relation %d has an unconditional label for a conditional source claim", i+1) } if graphAffirmativeClaim(relation.Type) && !graphUseOrEfficacyLabel(relation.Type) && graphConditionPattern.MatchString(clause) && !graphConditionPattern.MatchString(compactGraphText(relation.Evidence)) { return fmt.Errorf("graph relation %d omits a source condition from its evidence", i+1) } if graphGeneralClaim(relation.Type) { for _, qualifier := range graphClaimQualifierPattern.FindAllString(clause, -1) { if !strings.Contains(graphComparisonText(relation.Evidence), qualifier) { return fmt.Errorf("graph relation %d omits source attribution, time or uncertainty %q from its evidence", i+1, qualifier) } } for _, possibility := range graphPossibilityPattern.FindAllString(clause, -1) { if !strings.Contains(graphComparisonText(relation.Node1+relation.Node2), possibility) { return fmt.Errorf("graph relation %d turns source possibility %q into an unqualified claim; use 关联 or preserve that scope in the entity names", i+1, possibility) } } if graphOpinionPattern.MatchString(clause) && !graphOpinionScopePattern.MatchString(graphComparisonText(relation.Node1+relation.Node2)) { return fmt.Errorf("graph relation %d turns an attributed opinion into an unqualified claim; preserve the viewpoint in scoped entities or use an evidenced association", i+1) } } } quote := graphComparisonText(relation.Evidence) target := graphComparisonText(relation.Node2) storage := graphStoragePattern.MatchString(quote) || strings.Contains(quote, "不易触及") || (graphStorageLocationPattern.MatchString(quote) && graphStoragePattern.MatchString(graphComparisonText(strings.Join(contexts, ";")))) if storage && (graphPositiveUseLabel(relation.Type) || negativeUse || caution) { return fmt.Errorf("graph relation %d misclassifies a storage constraint as suitability or a use prohibition; use 约束 with the complete storage action", i+1) } if relation.Type == "约束" { if graphRestrictedCombinedUsePattern.MatchString(quote) && graphHasProhibition(quote) && !graphHasProhibition(target) { return fmt.Errorf("graph relation %d omits the negation of a combined-use constraint; retain 避免/不得 and the combined-use operation in entity2", i+1) } if graphStopUsePattern.MatchString(quote) && (!graphStopUsePattern.MatchString(target) || (graphImmediatePattern.MatchString(quote) && !graphImmediatePattern.MatchString(target))) { return fmt.Errorf("graph relation %d omits the stop-use action or its urgency; entity2 must preserve the condition and 立即停止使用", i+1) } if graphStopUsePattern.MatchString(quote) { for _, condition := range graphStopUseConditionPattern.FindAllStringSubmatch(quote, -1) { conditionText := graphStopUsePattern.Split(condition[1], 2)[0] conditionText = strings.TrimRight(conditionText, "请应需须立即马上") if conditionText != "" && !strings.Contains(graphComparisonText(relation.Node1+relation.Node2), conditionText) { return fmt.Errorf("graph relation %d omits the stop-use condition %q", i+1, conditionText) } } } if storage && (!graphStorageActionPattern.MatchString(target) || target == "贮存条件" || target == "贮存方法" || target == "储存条件") { return fmt.Errorf("graph relation %d omits the storage action; preserve 避光/避免直晒/存放 and relevant conditions in entity2", i+1) } if strings.Contains(quote, "不易触及") && strings.Contains(target, "儿童") && !strings.Contains(target, "不易触及") && !strings.Contains(target, "远离儿童") { return fmt.Errorf("graph relation %d omits the child-access storage restriction; preserve 存放于儿童不易触及处 in entity2", i+1) } } if negativeUse && !graphSubstitutionScopePattern.MatchString(quote) { for _, match := range graphIdentityDisclaimerPattern.FindAllStringSubmatch(quote, -1) { if target == match[1] { return fmt.Errorf("graph relation %d misclassifies an identity disclaimer as a use prohibition; keep the disclaimer as an entity attribute", i+1) } } } if ((negativeUse || caution) && !restrictionFound) || (relation.Type == "不支持" && !deniedSupport) || (relation.Type == "不导致" && !deniedCausation) { return fmt.Errorf("graph relation %d has a negative use label without a source restriction", i+1) } if graphPositiveUseLabel(relation.Type) { if graphStoragePattern.MatchString(target) || (graphStorageLocationPattern.MatchString(target) && graphStoragePattern.MatchString(compactGraphText(strings.Join(contexts, ";")))) { return fmt.Errorf("graph relation %d misclassifies a storage constraint as suitability", i+1) } if !graphUseClaimPattern.MatchString(quote) { return fmt.Errorf("graph relation %d has a suitability label without an explicit source use claim", i+1) } if conditionalUse { if !graphConditionPattern.MatchString(quote) { return fmt.Errorf("graph relation %d has conditional suitability without an explicit condition in its evidence", i+1) } if !graphConditionalUseTargetGrounded(quote, target) { return fmt.Errorf("graph relation %d has conditional suitability without a source-grounded use target", i+1) } } } if relation.Type == "抑制" && (!strings.Contains(quote, target) || !graphSuppressionPattern.MatchString(strings.ReplaceAll(quote, target, ""))) { return fmt.Errorf("graph relation %d has no explicit suppression predicate for its target", i+1) } if (relation.Type == "支持" || relation.Type == "促进" || relation.Type == "增强") && graphMicroorganismPattern.MatchString(target) && !graphSuppressionPattern.MatchString(target) && graphSuppressionPattern.MatchString(quote) { return fmt.Errorf("graph relation %d reverses source suppression into support", i+1) } if negativeUse || relation.Type == "约束" { endpoints := graphComparisonText(relation.Node1 + relation.Node2) scopedClaim := strings.Join(clauses, ";") for _, match := range graphRestrictedSitePattern.FindAllStringSubmatch(scopedClaim, -1) { if !strings.Contains(endpoints, match[2]) { return fmt.Errorf("graph relation %d omits the body-site scope %q of a source restriction; preserve it in the entity names", i+1, match[2]) } } if graphHasProhibition(scopedClaim) { for _, match := range graphRestrictedUseScopePattern.FindAllStringSubmatch(scopedClaim, -1) { if !strings.Contains(endpoints, match[1]) { return fmt.Errorf("graph relation %d omits the use scope %q of a source restriction; preserve it in the entity names", i+1, match[1]) } } for _, match := range graphRestrictedCombinedUsePattern.FindAllStringSubmatch(scopedClaim, -1) { if !strings.Contains(endpoints, match[1]) || !graphCombinedUseScopePattern.MatchString(endpoints) { return fmt.Errorf("graph relation %d omits the combined-use scope of a source restriction; preserve the other product and combined use in the entity names", i+1) } } for _, match := range graphRestrictedSubstitutionPattern.FindAllStringSubmatch(scopedClaim, -1) { if !strings.Contains(endpoints, match[1]) || !graphSubstitutionScopePattern.MatchString(endpoints) { return fmt.Errorf("graph relation %d omits the substitution scope of a source restriction; preserve substitution and its object in the entity names", i+1) } } } } } return nil } // QAPromptGenerator is a struct for generating QA prompts type QAPromptGenerator struct { Formater *Formater Template *types.PromptTemplateStructured ExamplesHeading string QuestionHeading string QuestionPrefix string AnswerPrefix string } // NewQAPromptGenerator creates a new QA prompt generator func NewQAPromptGenerator(formater *Formater, template *types.PromptTemplateStructured) *QAPromptGenerator { return &QAPromptGenerator{ Formater: formater, Template: template, ExamplesHeading: "# Examples", QuestionHeading: "# Question", QuestionPrefix: "Q: ", AnswerPrefix: "A: ", } } // System generates a system prompt func (qa *QAPromptGenerator) System(ctx context.Context) string { promptLines := []string{} if len(qa.Template.Tags) == 0 { promptLines = append(promptLines, qa.Template.Description) } else { tags, _ := json.Marshal(qa.Template.Tags) promptLines = append(promptLines, fmt.Sprintf(qa.Template.Description, string(tags))) } if len(qa.Template.Examples) > 0 { promptLines = append(promptLines, qa.ExamplesHeading) for _, example := range qa.Template.Examples { // Question promptLines = append(promptLines, fmt.Sprintf("%s%s", qa.QuestionPrefix, strings.TrimSpace(example.Text))) // Answer answer, err := qa.Formater.formatExtraction(example.Node, example.Relation) if err != nil { return "" } promptLines = append(promptLines, fmt.Sprintf("%s%s", qa.AnswerPrefix, answer)) // new line promptLines = append(promptLines, "") } } return strings.Join(promptLines, "\n") } // User generates a user prompt func (qa *QAPromptGenerator) User(ctx context.Context, question string) string { promptLines := []string{} promptLines = append(promptLines, qa.QuestionHeading) promptLines = append(promptLines, fmt.Sprintf("%s%s", qa.QuestionPrefix, question)) promptLines = append(promptLines, qa.AnswerPrefix) return strings.Join(promptLines, "\n") } // Render renders a prompt func (qa *QAPromptGenerator) Render(ctx context.Context, question string) []chat.Message { return []chat.Message{ { Role: "system", Content: qa.System(ctx), }, { Role: "user", Content: qa.User(ctx, question), }, } } // FormatType is a type for format types type FormatType string const ( // FormatTypeJSON is a format type for JSON FormatTypeJSON FormatType = "json" // FormatTypeYAML is a format type for YAML FormatTypeYAML FormatType = "yaml" ) const ( _FENCE_START = "```" _LANGUAGE_TAG = `(?P[A-Za-z0-9_+-]+)?` _FENCE_NEWLINE = `(?:\s*\n)?` _FENCE_BODY = `(?P[\s\S]*?)` _FENCE_END = "```" ) var _FENCE_RE = regexp.MustCompile( _FENCE_START + _LANGUAGE_TAG + _FENCE_NEWLINE + _FENCE_BODY + _FENCE_END, ) // Formater is a struct for formatting entities type Formater struct { attributeSuffix string formatType FormatType useFences bool nodePrefix string relationSource string relationTarget string relationPrefix string } // NewFormater creates a new formater func NewFormater() *Formater { return &Formater{ attributeSuffix: "_attributes", formatType: FormatTypeJSON, useFences: true, nodePrefix: "entity", relationSource: "entity1", relationTarget: "entity2", relationPrefix: "relation", } } // formatExtraction formats extraction func (f *Formater) formatExtraction(nodes []*types.GraphNode, relations []*types.GraphRelation) (string, error) { items := make([]map[string]interface{}, 0) for _, node := range nodes { item := map[string]interface{}{ f.nodePrefix: node.Name, } if len(node.Attributes) > 0 { item[fmt.Sprintf("%s%s", f.nodePrefix, f.attributeSuffix)] = node.Attributes } items = append(items, item) } for _, relation := range relations { item := map[string]interface{}{ f.relationSource: relation.Node1, f.relationTarget: relation.Node2, f.relationPrefix: relation.Type, } if relation.Evidence != "" { item["evidence"] = relation.Evidence } items = append(items, item) } formatted := "" switch f.formatType { default: formattedBytes, err := json.MarshalIndent(items, "", " ") if err != nil { return "", err } formatted = string(formattedBytes) } if f.useFences { formatted = f.addFences(formatted) } return formatted, nil } func (f *Formater) parseOutput(ctx context.Context, text string) ([]map[string]interface{}, error) { if text == "" { return nil, errors.New("empty or invalid input string") } content := f.extractContent(ctx, text) // logger.Debugf(ctx, "Extracted content: %s", content) if content == "" { return nil, errors.New("empty or invalid input string") } var parsed interface{} var err error if f.formatType == FormatTypeJSON { err = json.Unmarshal([]byte(content), &parsed) } if err != nil { return nil, fmt.Errorf("failed to parse %s content: %s", strings.ToUpper(string(f.formatType)), err.Error()) } if parsed == nil { return nil, fmt.Errorf("content must be a list of extractions or a dict") } var items []interface{} if parsedMap, ok := parsed.(map[string]interface{}); ok { items = []interface{}{parsedMap} } else if parsedList, ok := parsed.([]interface{}); ok { items = parsedList } else { return nil, fmt.Errorf("expected list or dict, got %T", parsed) } itemsList := make([]map[string]interface{}, 0) for _, item := range items { if itemMap, ok := item.(map[string]interface{}); ok { itemsList = append(itemsList, itemMap) } else { return nil, fmt.Errorf("each item in the sequence must be a mapping.") } } return itemsList, nil } func (f *Formater) ParseGraph(ctx context.Context, text string) (*types.GraphData, error) { return f.parseGraph(ctx, text, nil) } func (f *Formater) parseGraph(ctx context.Context, text string, sourceContent *string) (*types.GraphData, error) { matchData, err := f.parseOutput(ctx, text) if err != nil { return nil, err } if len(matchData) == 0 { logger.Debugf(ctx, "received empty extraction data.") return &types.GraphData{}, nil } // mm, _ := json.Marshal(matchData) // logger.Debugf(ctx, "Parsed graph data: %s", string(mm)) var nodes []*types.GraphNode var relations []*types.GraphRelation for _, group := range matchData { switch { case group[f.nodePrefix] != nil: if sourceContent != nil { name, ok := group[f.nodePrefix].(string) if !ok || strings.TrimSpace(name) == "" || group[f.relationSource] != nil || group[f.relationTarget] != nil { return nil, errors.New("graph extraction returned an invalid entity item") } } attributes := make([]string, 0) attributesKey := f.nodePrefix + f.attributeSuffix if attr, ok := group[attributesKey].([]interface{}); ok { for _, v := range attr { attributes = append(attributes, fmt.Sprintf("%v", v)) } } nodes = append(nodes, &types.GraphNode{ Name: fmt.Sprintf("%v", group[f.nodePrefix]), Attributes: attributes, }) case group[f.relationSource] != nil && group[f.relationTarget] != nil: if sourceContent != nil { for _, key := range []string{f.relationSource, f.relationTarget, f.relationPrefix} { if value, ok := group[key].(string); !ok || strings.TrimSpace(value) == "" { return nil, errors.New("graph extraction returned an invalid relation item") } } } evidence, _ := group["evidence"].(string) relations = append(relations, &types.GraphRelation{ Node1: fmt.Sprintf("%v", group[f.relationSource]), Node2: fmt.Sprintf("%v", group[f.relationTarget]), Type: fmt.Sprintf("%v", group[f.relationPrefix]), Evidence: evidence, }) default: if sourceContent != nil { return nil, errors.New("graph extraction returned an unsupported item") } logger.Warnf(ctx, "Unsupported graph group: %v", group) continue } } graph := &types.GraphData{ Node: nodes, Relation: relations, } if len(nodes) == 0 && len(relations) == 0 { return nil, fmt.Errorf("model returned no recognized graph extractions; only an explicit empty list denotes an empty result") } if sourceContent != nil { if err := errors.Join(validateGraphRelationEvidence(graph, *sourceContent), validateGraphPriceAttributes(graph, *sourceContent), validateGraphRestrictionAttributes(graph, *sourceContent), validateGraphStorageCoverage(graph, *sourceContent), validateGraphRestrictionCoverage(graph, *sourceContent)); err != nil { return nil, err } } f.rebuildGraph(ctx, graph) return graph, nil } func (f *Formater) rebuildGraph(ctx context.Context, graph *types.GraphData) { nodeMap := make(map[string]*types.GraphNode) nodes := make([]*types.GraphNode, 0, len(graph.Node)) for _, node := range graph.Node { if prenode, ok := nodeMap[node.Name]; ok { logger.Infof(ctx, "Duplicate node ID: %s, merge attribute", node.Name) // 修复panic:检查Attributes是否为nil if node.Attributes == nil { node.Attributes = make([]string, 0) } if prenode.Attributes != nil { node.Attributes = append(node.Attributes, prenode.Attributes...) } continue } nodeMap[node.Name] = node nodes = append(nodes, node) } relations := make([]*types.GraphRelation, 0, len(graph.Relation)) for _, relation := range graph.Relation { if relation.Node1 == relation.Node2 { logger.Infof(ctx, "Duplicate relation, ignore it") continue } if _, ok := nodeMap[relation.Node1]; !ok { node := &types.GraphNode{Name: relation.Node1} nodes = append(nodes, node) nodeMap[relation.Node1] = node logger.Infof(ctx, "Add unknown source node ID: %s", relation.Node1) } if _, ok := nodeMap[relation.Node2]; !ok { node := &types.GraphNode{Name: relation.Node2} nodes = append(nodes, node) nodeMap[relation.Node2] = node logger.Infof(ctx, "Add unknown target node ID: %s", relation.Node2) } relations = append(relations, relation) } *graph = types.GraphData{ Node: nodes, Relation: relations, } } func (f *Formater) extractContent(ctx context.Context, text string) string { if !f.useFences { return strings.TrimSpace(text) } validTags := map[FormatType]map[string]struct{}{ FormatTypeYAML: {"yaml": {}, "yml": {}}, FormatTypeJSON: {"json": {}}, } matches := _FENCE_RE.FindAllStringSubmatch(text, -1) var candidates []string for _, match := range matches { lang := match[1] body := match[2] if f.isValidLanguageTag(lang, validTags) { candidates = append(candidates, body) } } switch { case len(candidates) == 1: return strings.TrimSpace(candidates[0]) case len(candidates) > 1: logger.Warnf(ctx, "multiple candidates found: %d", len(candidates)) return strings.TrimSpace(candidates[0]) case len(matches) == 1: logger.Debugf(ctx, "no candidate found, use first match without language tag: %s", matches[0][1]) return strings.TrimSpace(matches[0][2]) case len(matches) > 1: logger.Warnf(ctx, "multiple matches found: %d", len(matches)) return strings.TrimSpace(matches[0][2]) default: // Fallback strategies for cases where the fence regex fails to match. // This commonly happens when: // 1. The LLM output is truncated (no closing fence) — issue #1113 Pattern 3. // 2. The opening fence is malformed or surrounded by unexpected content, // so the non-greedy regex falls back to the raw text — issue #1113 Pattern 1. // Without these fallbacks, the raw text (including backticks) is passed to // json.Unmarshal and fails with `invalid character '`'`. if extracted := stripFencesAndExtract(text, f.formatType); extracted != "" { logger.Debugf(ctx, "no fence match, recovered content via fallback (%d bytes)", len(extracted)) return extracted } logger.Warnf(ctx, "no match found") return strings.TrimSpace(text) } } // stripFencesAndExtract attempts to recover a parseable payload from an LLM // response when the strict fence regex fails. It handles three common cases: // // 1. Truncated responses with an opening ```lang fence but no closing fence // (LLM hit max_tokens mid-output). // 2. Responses where the JSON/YAML body is preceded or followed by prose // and the fences are present but malformed. // 3. Responses with no fences at all but a recognizable JSON object/array // embedded in surrounding text. // // It returns an empty string when no plausible payload can be recovered, so // callers can fall back to their own behavior. func stripFencesAndExtract(text string, format FormatType) string { trimmed := strings.TrimSpace(text) if trimmed == "" { return "" } // Case 1: opening fence present (with or without language tag) but no // matching closing fence. Take everything after the first fence and // strip any trailing backticks. if idx := strings.Index(trimmed, "```"); idx >= 0 { rest := trimmed[idx+3:] // Drop optional language tag on the same line. if nl := strings.IndexByte(rest, '\n'); nl >= 0 { firstLine := strings.TrimSpace(rest[:nl]) // A pure language tag is short and alphanumeric-ish. if firstLine == "" || isLikelyLanguageTag(firstLine) { rest = rest[nl+1:] } } // If there is a closing fence somewhere, cut at it. if end := strings.Index(rest, "```"); end >= 0 { rest = rest[:end] } rest = strings.TrimSpace(rest) rest = strings.Trim(rest, "`") rest = strings.TrimSpace(rest) if rest != "" { return rest } } // Case 2: no usable fence found, but the payload may still contain a // JSON object/array. Extract the outermost {...} or [...] substring. if format == FormatTypeJSON { if extracted := extractJSONLike(trimmed); extracted != "" { return extracted } } return "" } // isLikelyLanguageTag reports whether s looks like a markdown fence language // tag (e.g. "json", "yaml", "yml", "go"). It must be short and contain only // characters typical for a language identifier. func isLikelyLanguageTag(s string) bool { if s == "" || len(s) > 16 { return false } for _, r := range s { switch { case r >= 'a' && r <= 'z': case r >= 'A' && r <= 'Z': case r >= '0' && r <= '9': case r == '_' || r == '-' || r == '+': default: return false } } return true } // extractJSONLike returns the outermost JSON object or array substring from s, // or an empty string if none is found. It picks whichever bracket type appears // first in the input, which mirrors what the LLM is most likely to have // produced. The returned slice is not validated as JSON; callers must still // json.Unmarshal it. func extractJSONLike(s string) string { objStart := strings.IndexByte(s, '{') arrStart := strings.IndexByte(s, '[') var open, closeCh byte var start int switch { case objStart < 0 && arrStart < 0: return "" case objStart < 0: open, closeCh, start = '[', ']', arrStart case arrStart < 0: open, closeCh, start = '{', '}', objStart case objStart < arrStart: open, closeCh, start = '{', '}', objStart default: open, closeCh, start = '[', ']', arrStart } // Find matching close, respecting string literals so braces/brackets // inside JSON strings don't unbalance the count. depth := 0 inString := false escaped := false for i := start; i < len(s); i++ { c := s[i] if inString { if escaped { escaped = false continue } switch c { case '\\': escaped = true case '"': inString = false } continue } switch c { case '"': inString = true case open: depth++ case closeCh: depth-- if depth == 0 { return strings.TrimSpace(s[start : i+1]) } } } return "" } func (f *Formater) addFences(content string) string { content = strings.TrimSpace(content) return fmt.Sprintf("```%s\n%s\n```", f.formatType, content) } func (f *Formater) isValidLanguageTag(lang string, validTags map[FormatType]map[string]struct{}) bool { if lang == "" { return true } tag := strings.TrimSpace(strings.ToLower(lang)) validSet, ok := validTags[f.formatType] if !ok { return false } _, exists := validSet[tag] return exists }