Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
91 KiB
JSON
1 line
91 KiB
JSON
{"repo": "anincoder/multi-model-geo-analysis-system", "files": {"versions/2026-07-19-before-webpage-annotation-crawl-fix/backend/src/test/java/com/example/geo/AnalyzerSelfCheckTest.java": {"bd363d90e647f97f90e1507abcc387352a5ced48": {"t": 1785746123.3245778, "n": 1517, "h": "3e7d6ed3dc758744", "c": "package com.example.geo;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport org.junit.jupiter.api.Test;\n\nclass AnalyzerSelfCheckTest {\n private final AnalyzerService service = new AnalyzerService(\n \"test-key\",\n \"https://api.deepseek.com/chat/completions\",\n \"deepseek-test\",\n new ObjectMapper()\n );\n\n @Test\n void repairsCommonNonStrictJsonFromAnalyzer() {\n String json = \"\"\"\n {\n objects: [\n {\n name: 'Nintendo Switch',\n canonicalName: 'Nintendo',\n mentionCount: 2,\n mentionRate: 1.0,\n averageRank: 1,\n positiveScore: 8,\n negativeScore: 0,\n scenarios: ['family', 'portable'],\n analysisNote: 'same entity',\n },\n ],\n suggestion: 'Choose Nintendo Switch for portable party games.',\n shortSummary: 'Nintendo Switch is the first choice.',\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).hasSize(1);\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"Nintendo\");\n assertThat(output.metrics().getFirst().scenarioTags()).contains(\"family\", \"portable\");\n }\n}\n"}}, "backend/src/test/java/com/example/geo/AnalyzerServiceTest.java": {"bd363d90e647f97f90e1507abcc387352a5ced48": {"t": 1785746123.3621926, "n": 17573, "h": "f2de37266ac078fa", "c": "package com.example.geo;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\n\nclass AnalyzerServiceTest {\n private final AnalyzerService service = new AnalyzerService(\n \"test-key\",\n \"https://api.deepseek.com/chat/completions\",\n \"deepseek-test\",\n new ObjectMapper()\n );\n\n @Test\n void reportsMissingAnalyzerConfiguration() {\n AnalyzerService unconfigured = new AnalyzerService(\"\", \"\", \"\", new ObjectMapper());\n\n var attempt = unconfigured.analyzeWithDiagnostics(\n new AnalysisRequest(\"Which app should I choose?\", UserRole.CONSUMER, \"\", List.of(), \"FULL_REPORT\"),\n List.of()\n );\n\n assertThat(attempt.output()).isEmpty();\n assertThat(attempt.message()).contains(\"not configured\");\n }\n\n @Test\n void parsesAnalyzerJsonIntoMetricsAndSuggestions() {\n String json = \"\"\"\n {\n \"objects\": [\n {\n \"name\": \"德龙\",\n \"mentionCount\": 3,\n \"mentionRate\": 1.0,\n \"averageRank\": 1,\n \"positiveScore\": 7,\n \"negativeScore\": 1,\n \"scenarios\": [\"入门\", \"全自动\", \"售后\"],\n \"analysisNote\": \"德龙在两家模型中都被作为入门全自动候选。\"\n }\n ],\n \"suggestion\": \"建议优先考虑德龙,原因是提及集中,适合入门全自动用户;Nespresso 可作为胶囊咖啡备选。\",\n \"shortSummary\": \"普通消费者可优先看德龙,重视省事可看 Nespresso。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).hasSize(1);\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"德龙\");\n assertThat(output.metrics().getFirst().scenarioTags()).contains(\"全自动\", \"售后\");\n assertThat(output.suggestion()).contains(\"优先考虑德龙\");\n assertThat(output.shortSummary()).contains(\"德龙\");\n }\n\n @Test\n void parsesJsonInsideMarkdownFence() {\n String content = \"\"\"\n ```json\n {\n \"objects\": [\n {\n \"name\": \"飞利浦\",\n \"mentionCount\": 2,\n \"mentionRate\": 1.0,\n \"averageRank\": null,\n \"positiveScore\": 5,\n \"negativeScore\": 0,\n \"scenarios\": [\"家用\"],\n \"analysisNote\": \"未识别到明确排序\"\n }\n ],\n \"suggestion\": \"飞利浦适合作为家用备选。\",\n \"shortSummary\": \"家用可比较飞利浦。\"\n }\n ```\n \"\"\";\n\n var output = service.parseAnalyzerJson(content).orElseThrow();\n\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"飞利浦\");\n assertThat(output.metrics().getFirst().averageRank()).isNull();\n }\n\n @Test\n void parsesJsonSurroundedByPlainText() {\n String content = \"\"\"\n 下面是结构化分析:\n {\n \"objects\": [\n {\n \"name\": \"塞尔达传说:旷野之息\",\n \"mentionCount\": 1,\n \"mentionRate\": 0.5,\n \"averageRank\": 1,\n \"positiveScore\": 8,\n \"negativeScore\": 0,\n \"scenarios\": [\"开放世界\", \"解谜\"],\n \"analysisNote\": \"适合喜欢自由探索的玩家。\"\n }\n ],\n \"suggestion\": \"普通消费者可优先考虑塞尔达传说:旷野之息,原因是自由度高、口碑稳定。\",\n \"shortSummary\": \"想买好玩的游戏可优先看塞尔达传说:旷野之息。\"\n }\n 以上 JSON 可用于图表。\n \"\"\";\n\n var output = service.parseAnalyzerJson(content).orElseThrow();\n\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"塞尔达传说:旷野之息\");\n assertThat(output.suggestion()).contains(\"优先考虑\");\n }\n\n @Test\n void filtersNonObjectNamesFromStructuredAnalyzerJson() {\n String json = \"\"\"\n {\n \"objects\": [\n {\"name\": \"五粮液\", \"mentionCount\": 3, \"mentionRate\": 1.0, \"positiveScore\": 5, \"negativeScore\": 1},\n {\"name\": \"用户痛点\", \"mentionCount\": 2, \"mentionRate\": 0.5},\n {\"name\": \"连续两任董事长\", \"mentionCount\": 2, \"mentionRate\": 0.5},\n {\"name\": \"BrandTest\", \"mentionCount\": 1, \"mentionRate\": 0.5},\n {\"name\": \"预算限制关键词\", \"mentionCount\": 1, \"mentionRate\": 0.5},\n {\"name\": \"你的预算\", \"mentionCount\": 1, \"mentionRate\": 0.5},\n {\"name\": \"头部品牌\", \"mentionCount\": 1, \"mentionRate\": 0.5}\n ],\n \"suggestion\": \"建议强化五粮液的结构化表达。\",\n \"shortSummary\": \"五粮液更突出。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).extracting(BrandMetric::brandName)\n .contains(\"五粮液\")\n .doesNotContain(\"用户痛点\", \"连续两任董事长\", \"BrandTest\", \"预算限制关键词\", \"你的预算\", \"头部品牌\");\n }\n\n @Test\n void filtersTrainingDescriptorNamesFromStructuredAnalyzerJson() {\n String json = \"\"\"\n {\n \"objects\": [\n {\"name\": \"编程猫\", \"mentionCount\": 3, \"mentionRate\": 1.0, \"positiveScore\": 6, \"negativeScore\": 1},\n {\"name\": \"小码王\", \"mentionCount\": 2, \"mentionRate\": 0.7, \"positiveScore\": 5, \"negativeScore\": 1},\n {\"name\": \"2026年少儿编程教育\", \"mentionCount\": 1, \"mentionRate\": 0.3},\n {\"name\": \"对注重课堂\", \"mentionCount\": 1, \"mentionRate\": 0.3},\n {\"name\": \"获芬兰EAF国际教育\", \"mentionCount\": 1, \"mentionRate\": 0.3},\n {\"name\": \"重点感受老师的引导方式和课堂\", \"mentionCount\": 1, \"mentionRate\": 0.3}\n ],\n \"suggestion\": \"建议优先比较编程猫和小码王。\",\n \"shortSummary\": \"编程猫和小码王更像真实候选机构。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).extracting(BrandMetric::brandName)\n .contains(\"编程猫\", \"小码王\")\n .doesNotContain(\"2026年少儿编程教育\", \"对注重课堂\", \"获芬兰EAF国际教育\", \"重点感受老师的引导方式和课堂\");\n }\n\n @Test\n void doesNotUseTypeOrCategoryAsEntityName() {\n String json = \"\"\"\n {\n \"objects\": [\n {\n \"type\": \"人员核验\",\n \"category\": \"法律与保险\",\n \"entityType\": \"risk_dimension\",\n \"mentionCount\": 1,\n \"mentionRate\": 0.5,\n \"analysisNote\": \"这是评估维度,不是候选对象。\"\n },\n {\n \"brandName\": \"天鹅到家\",\n \"entityType\": \"brand\",\n \"mentionCount\": 2,\n \"mentionRate\": 1.0,\n \"positiveScore\": 7,\n \"negativeScore\": 2,\n \"analysisNote\": \"模型回答中作为家政服务品牌出现。\"\n }\n ],\n \"suggestion\": \"优先核验天鹅到家的人员资质和合同条款。\",\n \"shortSummary\": \"天鹅到家是明确候选品牌。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).extracting(BrandMetric::brandName)\n .containsExactly(\"天鹅到家\");\n }\n\n @Test\n void parsesFirstBalancedJsonWhenTrailingTextContainsBraces() {\n String content = \"\"\"\n ```json\n {\n \"objects\": [\n {\n \"name\": \"Coursera\",\n \"mentionCount\": 2,\n \"mentionRate\": 1.0,\n \"averageRank\": null,\n \"positiveScore\": 5,\n \"negativeScore\": 2,\n \"scenarios\": [\"证书背书\", \"自学\"],\n \"analysisNote\": \"适合自律性强且追求权威背书的用户。\"\n }\n ],\n \"suggestion\": \"优先按学习目标比较 Coursera、Udacity 和 Udemy。\",\n \"shortSummary\": \"培训机构选择要先看学习目标、资质和服务质量。\"\n }\n ```\n 说明:上面 JSON 后面可能还有诊断文本,例如 {not real json}。\n \"\"\";\n\n var output = service.parseAnalyzerJson(content).orElseThrow();\n\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"Coursera\");\n assertThat(output.suggestion()).contains(\"Coursera\");\n }\n\n @Test\n void prefersCanonicalNameWhenAnalyzerProvidesOne() {\n String json = \"\"\"\n {\n \"objects\": [\n {\n \"name\": \"iPhone\",\n \"canonicalName\": \"苹果\",\n \"mentionCount\": 2,\n \"mentionRate\": 1.0,\n \"averageRank\": 1,\n \"positiveScore\": 5,\n \"negativeScore\": 0,\n \"scenarios\": [\"高端\", \"生态\"],\n \"analysisNote\": \"iPhone 是苹果手机产品线表述。\"\n }\n ],\n \"suggestion\": \"苹果在多模型回答中表现突出。\",\n \"shortSummary\": \"建议强化苹果的结构化表达。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"苹果\");\n }\n\n @Test\n void extractsContentFromAlternativeChatResponseFields() throws Exception {\n String deltaResponse = \"\"\"\n {\"choices\":[{\"delta\":{\"content\":\"{\\\\\"objects\\\\\":[]}\"}}]}\n \"\"\";\n String textResponse = \"\"\"\n {\"choices\":[{\"text\":\"{\\\\\"objects\\\\\":[]}\"}]}\n \"\"\";\n String arrayContentResponse = \"\"\"\n {\"choices\":[{\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\\\"objects\\\\\":[]}\"}]}}]}\n \"\"\";\n String reasoningContentResponse = \"\"\"\n {\"choices\":[{\"message\":{\"reasoning_content\":\"{\\\\\"objects\\\\\":[]}\"}}]}\n \"\"\";\n\n assertThat(service.extractChatContent(deltaResponse)).contains(\"objects\");\n assertThat(service.extractChatContent(textResponse)).contains(\"objects\");\n assertThat(service.extractChatContent(arrayContentResponse)).contains(\"objects\");\n assertThat(service.extractChatContent(reasoningContentResponse)).contains(\"objects\");\n }\n\n @Test\n void extractsAndParsesStandardChatResponseContent() throws Exception {\n String response = \"\"\"\n {\n \"choices\": [\n {\n \"message\": {\n \"content\": \"{\\\\\"objects\\\\\":[{\\\\\"name\\\\\":\\\\\"A机构\\\\\",\\\\\"mentionCount\\\\\":1,\\\\\"mentionRate\\\\\":1,\\\\\"averageRank\\\\\":1,\\\\\"positiveScore\\\\\":3,\\\\\"negativeScore\\\\\":0,\\\\\"scenarios\\\\\":[\\\\\"服务\\\\\"],\\\\\"analysisNote\\\\\":\\\\\"第一推荐\\\\\"}],\\\\\"suggestion\\\\\":\\\\\"优先看 A机构。\\\\\",\\\\\"shortSummary\\\\\":\\\\\"A机构更突出。\\\\\"}\"\n }\n }\n ]\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(service.extractChatContent(response)).orElseThrow();\n\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"A机构\");\n assertThat(output.shortSummary()).contains(\"A机构\");\n }\n\n @Test\n void acceptsCommonAnalyzerSchemaVariants() {\n String json = \"\"\"\n {\n \"candidates\": [\n {\n \"brandName\": \"私立诊所\",\n \"mentions\": 3,\n \"mention_rate\": 1.0,\n \"rank\": 1,\n \"positive\": 8,\n \"negative\": 1,\n \"useCases\": [\"洗牙\", \"补牙\"],\n \"reason\": \"服务便利但需要核实资质。\"\n }\n ],\n \"advice\": \"普通洗牙补牙可比较私立诊所,但要确认资质和收费。\",\n \"summary\": \"私立诊所便利,但资质和价格透明度要重点核实。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"私立诊所\");\n assertThat(output.metrics().getFirst().mentionCount()).isEqualTo(3);\n assertThat(output.suggestion()).contains(\"私立诊所\");\n }\n\n @Test\n void acceptsNestedChineseAnalyzerSchema() {\n String json = \"\"\"\n {\n \"analysis\": {\n \"候选对象\": [\n {\n \"标准名称\": \"公立口腔专科医院\",\n \"提及次数\": 2,\n \"提及率\": 1.0,\n \"平均排序\": 1,\n \"正向分\": 7,\n \"负向分\": 0,\n \"场景\": [\"正畸\", \"种植牙\"],\n \"分析说明\": \"复杂项目更适合优先比较。\"\n }\n ]\n },\n \"结果\": {\n \"最终建议\": \"正畸、种植牙等复杂项目建议优先比较公立口腔专科医院。\",\n \"简要总结\": \"复杂看牙项目优先看公立口腔专科医院。\"\n }\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"公立口腔专科医院\");\n assertThat(output.metrics().getFirst().averageRank()).isEqualTo(1);\n assertThat(output.suggestion()).contains(\"公立口腔专科医院\");\n assertThat(output.shortSummary()).contains(\"复杂看牙项目\");\n }\n\n @Test\n void acceptsObjectMapAndCandidateTypeNames() {\n String json = \"\"\"\n {\n \"objects\": {\n \"公立医院口腔科\": {\n \"类型\": \"公立医院口腔科\",\n \"提及次数\": 1,\n \"提及率\": 1.0,\n \"正向分\": 6,\n \"负向分\": 0,\n \"场景\": [\"正畸\", \"种植牙\"],\n \"分析说明\": \"复杂项目更适合优先比较。\"\n },\n \"正规连锁诊所\": {\n \"candidateType\": \"正规连锁诊所\",\n \"mentionCount\": 1,\n \"mentionRate\": 1.0,\n \"positiveScore\": 4,\n \"negativeScore\": 0,\n \"scenarios\": [\"洗牙\", \"补牙\"]\n }\n },\n \"suggestion\": \"复杂项目优先公立医院口腔科,简单项目可比较正规连锁诊所。\",\n \"shortSummary\": \"看牙应按项目复杂度选择机构类型。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).extracting(BrandMetric::brandName)\n .contains(\"公立医院口腔科\", \"正规连锁诊所\");\n assertThat(output.suggestion()).contains(\"公立医院口腔科\");\n }\n\n @Test\n void cleansLongExplanationNamesFromAnalyzerObjects() {\n String json = \"\"\"\n {\n \"objects\": [\n {\n \"name\": \"建议优先选择正规、高口碑机构,根据牙齿问题类型对比几家再决定。\",\n \"mentionCount\": 1,\n \"mentionRate\": 1.0,\n \"positiveScore\": 3,\n \"negativeScore\": 0,\n \"analysisNote\": \"这是一句建议,不是候选对象。\"\n },\n {\n \"name\": \"由于当前没有获取到相关回答,建议综合考虑以下因素:1. 选择正规口腔医院或连锁诊所;2. 明确医生资质与费用。\",\n \"mentionCount\": 1,\n \"mentionRate\": 1.0,\n \"positiveScore\": 3,\n \"negativeScore\": 0\n }\n ],\n \"suggestion\": \"优先比较正规连锁诊所和公立医院口腔科。\",\n \"shortSummary\": \"看牙要比较机构类型。\"\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).extracting(BrandMetric::brandName)\n .containsExactly(\"正规口腔医院\");\n }\n\n @Test\n void salvagesCompletedObjectsFromTruncatedAnalyzerJson() {\n String truncated = \"\"\"\n {\n \"objects\": [\n {\n \"name\": \"编程猫\",\n \"mentionCount\": 2,\n \"mentionRate\": 0.67,\n \"averageRank\": 2,\n \"positiveScore\": 7,\n \"negativeScore\": 1,\n \"scenarios\": [\"低龄启蒙\"],\n \"analysisNote\": \"适合兴趣启蒙。\"\n },\n {\n \"name\": \"核桃编程\",\n \"mentionCount\": 3,\n \"analysisNote\": \"响应在这里被截断\n \"\"\";\n\n var output = service.parsePartialAnalyzerJson(truncated).orElseThrow();\n\n assertThat(output.metrics()).extracting(BrandMetric::brandName)\n .containsExactly(\"编程猫\");\n assertThat(output.suggestion()).isNotBlank();\n assertThat(output.shortSummary()).isNotBlank();\n }\n\n @Test\n void parsesProfessionalImplementationPlan() {\n String json = \"\"\"\n {\n \"title\": \"品牌 GEO 改进方案\",\n \"diagnosis\": \"目标品牌在场景词和可核验事实上弱于竞品。\",\n \"actions\": [\"重写核心产品页\", \"建立竞品对比页\"],\n \"wordingRecommendations\": [\"使用结论、证据和限制三段式描述\"],\n \"keywordRecommendations\": [\"五粮液 送礼\", \"五粮液 浓香型\"],\n \"validationSteps\": [\"30 天后复测首位推荐率\"]\n }\n \"\"\";\n\n ImplementationPlan plan = service.parseImplementationPlanJson(json).orElseThrow();\n\n assertThat(plan.title()).isEqualTo(\"品牌 GEO 改进方案\");\n assertThat(plan.actions()).contains(\"重写核心产品页\");\n assertThat(plan.keywordRecommendations()).contains(\"五粮液 送礼\");\n }\n}\n"}}, "backend/src/main/java/com/example/geo/ClarificationService.java": {"bd363d90e647f97f90e1507abcc387352a5ced48": {"t": 1785746125.1407764, "n": 12781, "h": "3c13d25af62ec17e", "c": "package com.example.geo;\n\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.http.MediaType;\nimport org.springframework.stereotype.Service;\nimport org.springframework.web.client.RestClient;\n\n@Service\npublic class ClarificationService {\n private final String deepseekKey;\n private final String deepseekUrl;\n private final String deepseekModel;\n private final ObjectMapper objectMapper;\n private final RestClient restClient;\n\n public ClarificationService(\n @Value(\"${geo.deepseek.api-key:}\") String deepseekKey,\n @Value(\"${geo.deepseek.api-url:https://api.deepseek.com/chat/completions}\") String deepseekUrl,\n @Value(\"${geo.deepseek.model:deepseek-v4-flash}\") String deepseekModel,\n ObjectMapper objectMapper\n ) {\n this.deepseekKey = deepseekKey;\n this.deepseekUrl = deepseekUrl;\n this.deepseekModel = deepseekModel;\n this.objectMapper = objectMapper;\n this.restClient = RestClient.builder().build();\n }\n\n public ClarificationResponse clarify(ClarificationRequest request) {\n List<ClarificationQuestion> questions = clarifyWithModel(request).orElseGet(() -> fallbackQuestions(request));\n return new ClarificationResponse(buildRefinedPrompt(request, questions), questions);\n }\n\n private Optional<List<ClarificationQuestion>> clarifyWithModel(ClarificationRequest request) {\n if (!hasDeepSeekConfig()) {\n return Optional.empty();\n }\n try {\n Map<String, Object> body = new LinkedHashMap<>();\n body.put(\"model\", deepseekModel);\n body.put(\"temperature\", 0.2);\n body.put(\"max_tokens\", 1200);\n body.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n body.put(\"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", \"\"\"\n 你是 GEO 分析任务的需求澄清顾问,只输出合法 JSON。\n 你的目标是根据用户问题、用户身份和身份侧重点,生成若干个必须确认的问题。\n 问题数量不固定,以澄清到足以交给网页端大模型回答为准,通常 3 到 7 个。\n 每个问题必须有 3 到 6 个具体选项,选项必须贴合用户原问题,不要使用泛泛模板。\n 不要强行套用固定问题;但必须让问题体现用户身份。例如品牌方更关注目标品牌是否被推荐、推荐排序、竞品表现、内容缺口和可优化资产;消费者更关注购买/选择条件、预算、使用场景和风险。\n 不要要求用户填写长文本,不要输出 Markdown。\n JSON 顶层格式:{\"questions\":[{\"id\":\"snake_case\",\"question\":\"...\",\"options\":[\"...\"],\"required\":true}]}。\n \"\"\"),\n Map.of(\"role\", \"user\", \"content\", \"用户问题:\" + request.question()\n + \"\\n用户身份:\" + request.userRole().label()\n + \"\\n目标对象:\" + (hasText(request.targetBrand()) ? request.targetBrand() : \"未填写\")\n + \"\\n品牌事实或优势资料:\" + (hasText(request.brandFacts()) ? request.brandFacts() : \"未填写\")\n + \"\\n身份对应的分析侧重点:\" + roleGuidance(request))\n ));\n String response = restClient.post()\n .uri(normalizeChatCompletionsUrl(deepseekUrl))\n .contentType(MediaType.APPLICATION_JSON)\n .header(\"Authorization\", \"Bearer \" + deepseekKey)\n .body(body)\n .retrieve()\n .body(String.class);\n String content = extractChatContent(response);\n List<ClarificationQuestion> questions = parseQuestions(content);\n return questions.isEmpty() ? Optional.empty() : Optional.of(questions);\n } catch (Exception e) {\n return Optional.empty();\n }\n }\n\n private String roleGuidance(ClarificationRequest request) {\n if (request.userRole() == UserRole.BRAND_EMPLOYEE || request.userRole() == UserRole.MARKETER) {\n return \"这是品牌方/运营视角。澄清问题应提醒模型关注目标品牌在回答中是否被主动推荐、推荐排序、被推荐或未被提及的原因、其他品牌/竞品的表现、负面或限制描述,以及后续官网 FAQ、产品页、对比页、内容资产的优化方向。\";\n }\n if (request.userRole() == UserRole.CONSUMER) {\n return \"这是普通消费者视角。澄清问题应优先确认预算、使用场景、核心偏好、风险限制、适用条件和最终选择标准。\";\n }\n if (request.userRole() == UserRole.RESEARCHER) {\n return \"这是研究视角。澄清问题应优先确认比较对象范围、证据标准、分析维度、模型差异和输出结构。\";\n }\n return \"根据用户身份生成贴合任务的澄清问题。\";\n }\n\n private List<ClarificationQuestion> fallbackQuestions(ClarificationRequest request) {\n List<ClarificationQuestion> questions = new ArrayList<>();\n questions.add(new ClarificationQuestion(\n \"decision_goal\",\n \"你最希望这次 GEO 分析回答哪类决策?\",\n decisionGoalOptions(request),\n true\n ));\n questions.add(new ClarificationQuestion(\n \"constraints\",\n \"有哪些必须纳入比较的限制条件?\",\n constraintOptions(request),\n true\n ));\n questions.add(new ClarificationQuestion(\n \"output_focus\",\n \"最终报告更应该突出什么?\",\n outputFocusOptions(request.userRole()),\n true\n ));\n if (isServiceQuestion(request.question())) {\n questions.add(new ClarificationQuestion(\n \"service_risk_focus\",\n \"你最担心服务选择中的哪类风险?\",\n List.of(\"资质不合规\", \"价格不透明\", \"服务质量不稳定\", \"售后或退款困难\"),\n true\n ));\n }\n if (isProductQuestion(request.question())) {\n questions.add(new ClarificationQuestion(\n \"usage_priority\",\n \"这次选择中最影响决策的使用优先级是什么?\",\n List.of(\"稳定耐用\", \"性价比\", \"高端体验\", \"专业性能\", \"售后保障\"),\n true\n ));\n }\n if (request.userRole() == UserRole.BRAND_EMPLOYEE || request.userRole() == UserRole.MARKETER) {\n questions.add(new ClarificationQuestion(\n \"content_asset\",\n \"你希望报告优先转化成哪类 GEO 内容资产?\",\n List.of(\"官网问答/FAQ\", \"产品页卖点\", \"竞品对比页\", \"社媒种草内容\", \"销售话术\"),\n true\n ));\n }\n return questions;\n }\n\n public String buildClarificationContext(List<ClarificationAnswer> answers) {\n if (answers == null || answers.isEmpty()) {\n return \"\";\n }\n StringBuilder builder = new StringBuilder(\"用户已确认的补充要求:\\n\");\n for (ClarificationAnswer answer : answers) {\n if (hasText(answer.question()) && !answer.selectedAnswers().isEmpty()) {\n builder.append(\"- \").append(answer.question().trim()).append(\":\")\n .append(answer.answerText()).append('\\n');\n }\n }\n return builder.toString().trim();\n }\n\n public String buildRefinedQuestion(AnalysisRequest request) {\n StringBuilder builder = new StringBuilder();\n builder.append(\"用户原始问题:\").append(request.question()).append('\\n');\n builder.append(\"用户身份:\").append(request.userRole().label()).append('\\n');\n if (hasText(request.targetBrand())) {\n builder.append(\"目标品牌或关注对象:\").append(request.targetBrand().trim()).append('\\n');\n }\n if (request.competitors() != null && !request.competitors().isEmpty()) {\n builder.append(\"竞品或备选对象:\").append(String.join(\"、\", request.competitors())).append('\\n');\n }\n if (hasText(request.brandFacts())) {\n builder.append(\"品牌事实或优势资料:\").append(request.brandFacts().trim()).append('\\n');\n }\n String clarificationContext = buildClarificationContext(request.clarificationAnswers());\n if (hasText(clarificationContext)) {\n builder.append(clarificationContext).append('\\n');\n }\n builder.append(\"请回答原始问题,并明确候选对象、推荐理由、适用条件、风险限制和可比较维度。\");\n return builder.toString();\n }\n\n private List<String> decisionGoalOptions(ClarificationRequest request) {\n if (request.userRole() == UserRole.CONSUMER) {\n return List.of(\"选择具体品牌/产品/机构\", \"比较候选类型或方案\", \"识别风险和避坑点\");\n }\n if (request.userRole() == UserRole.BRAND_EMPLOYEE || request.userRole() == UserRole.MARKETER) {\n return List.of(\"看目标对象在回答中是否被推荐\", \"找竞品内容差距\", \"形成官网/内容优化建议\");\n }\n return List.of(\"比较多模型回答差异\", \"提取候选对象和证据\", \"形成研究摘要\");\n }\n\n private List<String> constraintOptions(ClarificationRequest request) {\n if (isServiceQuestion(request.question())) {\n return List.of(\"预算/价格透明\", \"资质/服务质量\", \"地理位置/便利性\");\n }\n return List.of(\"预算价格\", \"使用场景\", \"品牌/型号/备选对象\");\n }\n\n private List<String> outputFocusOptions(UserRole userRole) {\n if (userRole == UserRole.CONSUMER) {\n return List.of(\"明确推荐顺序\", \"适用条件和备选方案\", \"风险提醒\");\n }\n if (userRole == UserRole.BRAND_EMPLOYEE || userRole == UserRole.MARKETER) {\n return List.of(\"GEO 内容优化清单\", \"竞品对比\", \"目标对象曝光问题\");\n }\n return List.of(\"结构化指标\", \"方法说明\", \"模型差异\");\n }\n\n private String buildRefinedPrompt(ClarificationRequest request, List<ClarificationQuestion> questions) {\n return \"请先确认以下 \" + questions.size() + \" 个问题,再进入网页端模型自动提问和 GEO 分析。\"\n + \"原始问题:\" + request.question()\n + \";用户身份:\" + request.userRole().label()\n + (hasText(request.targetBrand()) ? \";目标对象:\" + request.targetBrand() : \"\");\n }\n\n private boolean isServiceQuestion(String question) {\n return hasText(question) && List.of(\"看牙\", \"口腔\", \"医院\", \"律所\", \"装修\", \"培训\", \"家政\", \"机构\", \"诊所\")\n .stream().anyMatch(question::contains);\n }\n\n private boolean isProductQuestion(String question) {\n return hasText(question) && List.of(\"手机\", \"电脑\", \"咖啡机\", \"家电\", \"相机\", \"汽车\", \"白酒\", \"游戏\", \"平板\", \"App\")\n .stream().anyMatch(question::contains);\n }\n\n private List<ClarificationQuestion> parseQuestions(String content) throws Exception {\n if (!hasText(content)) {\n return List.of();\n }\n JsonNode root = objectMapper.readTree(extractJson(content));\n JsonNode array = root.path(\"questions\");\n if (!array.isArray()) {\n return List.of();\n }\n List<ClarificationQuestion> questions = new ArrayList<>();\n for (JsonNode item : array) {\n String question = item.path(\"question\").asText(\"\");\n JsonNode optionsNode = item.path(\"options\");\n if (!hasText(question) || !optionsNode.isArray()) {\n continue;\n }\n List<String> options = new ArrayList<>();\n for (JsonNode option : optionsNode) {\n String value = option.asText(\"\").trim();\n if (hasText(value) && !options.contains(value)) {\n options.add(value);\n }\n }\n if (options.size() < 2) {\n continue;\n }\n String id = item.path(\"id\").asText(\"\");\n if (!hasText(id)) {\n id = \"clarification_\" + (questions.size() + 1);\n }\n questions.add(new ClarificationQuestion(id, question.trim(), options.stream().limit(6).toList(),\n !item.has(\"required\") || item.path(\"required\").asBoolean(true)));\n if (questions.size() >= 8) {\n break;\n }\n }\n return questions;\n }\n\n private String extractChatContent(String response) throws Exception {\n JsonNode root = objectMapper.readTree(response);\n JsonNode choices = root.path(\"choices\");\n if (choices.isArray() && !choices.isEmpty()) {\n JsonNode first = choices.get(0);\n String content = first.path(\"message\").path(\"content\").asText(\"\");\n if (hasText(content)) {\n return content;\n }\n content = first.path(\"text\").asText(\"\");\n if (hasText(content)) {\n return content;\n }\n }\n return \"\";\n }\n\n private String extractJson(String content) {\n String trimmed = content.trim();\n int first = trimmed.indexOf('{');\n int last = trimmed.lastIndexOf('}');\n return first >= 0 && last > first ? trimmed.substring(first, last + 1) : trimmed;\n }\n\n private boolean hasDeepSeekConfig() {\n return hasText(deepseekKey) && hasText(deepseekUrl) && hasText(deepseekModel);\n }\n\n private String normalizeChatCompletionsUrl(String apiUrl) {\n String normalized = apiUrl.endsWith(\"/\") ? apiUrl.substring(0, apiUrl.length() - 1) : apiUrl;\n return normalized.endsWith(\"/chat/completions\") ? normalized : normalized + \"/chat/completions\";\n }\n\n private boolean hasText(String value) {\n return value != null && !value.isBlank();\n }\n}\n"}}, "versions/2026-07-19-webpage-annotation-crawl-fix-latest/backend/src/test/java/com/example/geo/AnalyzerSelfCheckTest.java": {"bd363d90e647f97f90e1507abcc387352a5ced48": {"t": 1785746132.9149573, "n": 1517, "h": "3e7d6ed3dc758744", "c": "package com.example.geo;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport org.junit.jupiter.api.Test;\n\nclass AnalyzerSelfCheckTest {\n private final AnalyzerService service = new AnalyzerService(\n \"test-key\",\n \"https://api.deepseek.com/chat/completions\",\n \"deepseek-test\",\n new ObjectMapper()\n );\n\n @Test\n void repairsCommonNonStrictJsonFromAnalyzer() {\n String json = \"\"\"\n {\n objects: [\n {\n name: 'Nintendo Switch',\n canonicalName: 'Nintendo',\n mentionCount: 2,\n mentionRate: 1.0,\n averageRank: 1,\n positiveScore: 8,\n negativeScore: 0,\n scenarios: ['family', 'portable'],\n analysisNote: 'same entity',\n },\n ],\n suggestion: 'Choose Nintendo Switch for portable party games.',\n shortSummary: 'Nintendo Switch is the first choice.',\n }\n \"\"\";\n\n var output = service.parseAnalyzerJson(json).orElseThrow();\n\n assertThat(output.metrics()).hasSize(1);\n assertThat(output.metrics().getFirst().brandName()).isEqualTo(\"Nintendo\");\n assertThat(output.metrics().getFirst().scenarioTags()).contains(\"family\", \"portable\");\n }\n}\n"}}, "versions/2026-07-19-webpage-annotation-crawl-fix-latest/backend/src/main/java/com/example/geo/AnalyzerService.java": {"bd363d90e647f97f90e1507abcc387352a5ced48": {"t": 1785746139.4795237, "n": 39166, "h": "49dc6bcf18cb22b3", "c": "package com.example.geo;\n\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.fasterxml.jackson.databind.node.ArrayNode;\nimport com.fasterxml.jackson.databind.node.ObjectNode;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.http.MediaType;\nimport org.springframework.stereotype.Service;\nimport org.springframework.web.client.RestClient;\n\n@Service\npublic class AnalyzerService {\n private static final int ANALYZER_MAX_TOKENS = 4096;\n private static final int ANALYZER_REPAIR_MAX_TOKENS = 4096;\n\n private final String deepseekKey;\n private final String deepseekUrl;\n private final String deepseekModel;\n private final RestClient restClient;\n private final ObjectMapper objectMapper;\n\n public AnalyzerService(\n @Value(\"${geo.deepseek.api-key:}\") String deepseekKey,\n @Value(\"${geo.deepseek.api-url:https://api.deepseek.com/chat/completions}\") String deepseekUrl,\n @Value(\"${geo.deepseek.model:deepseek-v4-flash}\") String deepseekModel,\n ObjectMapper objectMapper\n ) {\n this.deepseekKey = deepseekKey;\n this.deepseekUrl = deepseekUrl;\n this.deepseekModel = deepseekModel;\n this.objectMapper = objectMapper;\n this.restClient = RestClient.builder().build();\n }\n\n public Optional<AnalyzerOutput> analyze(AnalysisRequest request, List<ModelAnswer> answers) {\n return analyzeWithDiagnostics(request, answers).output();\n }\n\n public AnalyzerAttempt analyzeWithDiagnostics(AnalysisRequest request, List<ModelAnswer> answers) {\n if (!hasDeepSeekConfig()) {\n return new AnalyzerAttempt(Optional.empty(), \"DeepSeek analysis key, url or model is not configured.\");\n }\n try {\n String response = postChat(analyzerSystemPrompt(), analyzerUserPrompt(request, answers), ANALYZER_MAX_TOKENS);\n String content = extractChatContent(response);\n if (!hasText(content)) {\n Optional<AnalyzerOutput> strictRetry = retryStrictAnalysis(request, answers);\n return strictRetry\n .map(output -> new AnalyzerAttempt(Optional.of(output),\n \"DeepSeek response format was unexpected, strict structured retry succeeded.\"))\n .orElseGet(() -> new AnalyzerAttempt(Optional.empty(),\n \"DeepSeek analysis response did not contain usable message content. Response keys: \"\n + responseShape(response)));\n }\n\n Optional<AnalyzerOutput> output = parseAnalyzerJson(content);\n if (output.isPresent()) {\n return new AnalyzerAttempt(output, \"DeepSeek structured analysis succeeded.\");\n }\n\n Optional<AnalyzerOutput> partialOutput = parsePartialAnalyzerJson(content);\n if (partialOutput.isPresent()) {\n return new AnalyzerAttempt(partialOutput,\n \"DeepSeek response ended with incomplete JSON; completed object entries were preserved.\");\n }\n\n Optional<AnalyzerOutput> strictRetry = retryStrictAnalysis(request, answers);\n if (strictRetry.isPresent()) {\n return new AnalyzerAttempt(strictRetry, \"DeepSeek structured JSON failed once, strict structured retry succeeded.\");\n }\n\n Optional<AnalyzerOutput> repaired = repairAnalyzerJsonWithModel(content);\n return repaired.isPresent()\n ? new AnalyzerAttempt(repaired, \"DeepSeek structured JSON was repaired successfully.\")\n : new AnalyzerAttempt(Optional.empty(), \"DeepSeek structured JSON parse, strict retry and repair all failed. \"\n + analyzerContentShape(content));\n } catch (Exception e) {\n return new AnalyzerAttempt(Optional.empty(), \"DeepSeek analysis call failed: \" + safeMessage(e));\n }\n }\n\n public Optional<ImplementationPlan> analyzeImplementationPlan(\n AnalysisRequest request,\n List<ModelAnswer> answers,\n List<BrandMetric> metrics,\n List<WebEvidence> evidence\n ) {\n if (!hasDeepSeekConfig()) {\n return Optional.empty();\n }\n try {\n String response = postChat(implementationPlanSystemPrompt(request),\n implementationPlanUserPrompt(request, answers, metrics, evidence), 3072);\n return parseImplementationPlanJson(extractChatContent(response));\n } catch (Exception ignored) {\n return Optional.empty();\n }\n }\n\n private Optional<AnalyzerOutput> retryStrictAnalysis(AnalysisRequest request, List<ModelAnswer> answers) {\n try {\n String response = postChat(strictAnalyzerSystemPrompt(), strictAnalyzerUserPrompt(request, answers), ANALYZER_MAX_TOKENS);\n return parseAnalyzerJson(extractChatContent(response));\n } catch (Exception e) {\n return Optional.empty();\n }\n }\n\n private Optional<AnalyzerOutput> repairAnalyzerJsonWithModel(String content) {\n if (!hasText(content)) {\n return Optional.empty();\n }\n try {\n String response = postChat(\n \"你只负责把用户给出的内容修复为合法 JSON。不要重新分析,不要补充新对象,不要输出 Markdown 或解释。\",\n \"请修复下面内容,使其成为合法 JSON,并保留 objects、suggestion、shortSummary 字段:\\n\" + content,\n ANALYZER_REPAIR_MAX_TOKENS\n );\n return parseAnalyzerJson(extractChatContent(response));\n } catch (Exception e) {\n return Optional.empty();\n }\n }\n\n private String postChat(String systemPrompt, String userPrompt, int maxTokens) {\n try {\n return postChat(systemPrompt, userPrompt, maxTokens, true);\n } catch (RuntimeException responseFormatError) {\n return postChat(systemPrompt, userPrompt, maxTokens, false);\n }\n }\n\n private String postChat(String systemPrompt, String userPrompt, int maxTokens, boolean useJsonMode) {\n Map<String, Object> body = new LinkedHashMap<>();\n body.put(\"model\", deepseekModel);\n body.put(\"messages\", List.of(\n Map.of(\"role\", \"system\", \"content\", systemPrompt),\n Map.of(\"role\", \"user\", \"content\", userPrompt)\n ));\n body.put(\"temperature\", 0);\n body.put(\"max_tokens\", maxTokens);\n if (useJsonMode) {\n body.put(\"response_format\", Map.of(\"type\", \"json_object\"));\n }\n\n return restClient.post()\n .uri(normalizeChatCompletionsUrl(deepseekUrl))\n .contentType(MediaType.APPLICATION_JSON)\n .header(\"Authorization\", \"Bearer \" + deepseekKey)\n .body(body)\n .retrieve()\n .body(String.class);\n }\n\n String extractChatContent(String response) throws Exception {\n if (!hasText(response)) {\n return \"\";\n }\n JsonNode root = objectMapper.readTree(response);\n JsonNode choice = root.path(\"choices\").path(0);\n String content = extractText(choice.path(\"message\").path(\"content\"));\n if (hasText(content)) {\n return content;\n }\n content = extractText(choice.path(\"message\").path(\"reasoning_content\"));\n if (hasText(content)) {\n return content;\n }\n content = extractText(choice.path(\"delta\").path(\"content\"));\n if (hasText(content)) {\n return content;\n }\n content = extractText(choice.path(\"text\"));\n if (hasText(content)) {\n return content;\n }\n content = extractText(root.path(\"output_text\"));\n if (hasText(content)) {\n return content;\n }\n content = extractText(root.path(\"content\"));\n if (hasText(content)) {\n return content;\n }\n return extractText(root.path(\"output\"));\n }\n\n private String extractText(JsonNode node) {\n if (node == null || node.isMissingNode() || node.isNull()) {\n return \"\";\n }\n if (node.isTextual()) {\n return node.asText(\"\");\n }\n if (node.isArray()) {\n List<String> parts = new ArrayList<>();\n for (JsonNode item : node) {\n String text = extractText(item);\n if (hasText(text)) {\n parts.add(text);\n }\n }\n return String.join(\"\\n\", parts);\n }\n if (node.isObject()) {\n for (String field : List.of(\"text\", \"content\", \"output_text\", \"reasoning_content\")) {\n String text = extractText(node.path(field));\n if (hasText(text)) {\n return text;\n }\n }\n }\n return \"\";\n }\n\n Optional<AnalyzerOutput> parseAnalyzerJson(String content) {\n if (!hasText(content)) {\n return Optional.empty();\n }\n try {\n JsonNode root = readAnalyzerJson(content);\n JsonNode objects = firstArray(root, \"objects\", \"candidates\", \"brands\", \"items\", \"entities\",\n \"recommendations\", \"results\", \"analysisObjects\", \"metrics\", \"候选对象\", \"对象列表\", \"品牌列表\",\n \"推荐对象\", \"分析结果\", \"指标\");\n if (!objects.isArray() || objects.isEmpty()) {\n return Optional.empty();\n }\n\n List<BrandMetric> metrics = new ArrayList<>();\n for (JsonNode object : objects) {\n String name = firstEntityName(object);\n name = cleanObjectName(name);\n if (!hasText(name)) {\n continue;\n }\n int mentionCount = Math.max(0, firstInt(object, 0, \"mentionCount\", \"mentions\", \"count\",\n \"frequency\", \"mention_count\", \"提及次数\", \"提及数\", \"出现次数\"));\n double mentionRate = clampRate(firstDouble(object, 0, \"mentionRate\", \"mention_rate\", \"visibility\",\n \"exposureRate\", \"提及率\", \"可见度\", \"曝光率\"));\n JsonNode rankNode = firstNode(object, \"averageRank\", \"rank\", \"recommendRank\", \"ranking\",\n \"position\", \"平均排序\", \"推荐排序\", \"排序\", \"排名\");\n Integer averageRank = rankNode.isNull() || rankNode.isMissingNode()\n ? null\n : rankNode.asInt();\n int positiveScore = Math.max(0, firstInt(object, 0, \"positiveScore\", \"positive\", \"positive_count\",\n \"positiveMentions\", \"sentimentScore\", \"正向分\", \"正向次数\", \"正向倾向\"));\n int negativeScore = Math.max(0, firstInt(object, 0, \"negativeScore\", \"negative\", \"negative_count\",\n \"negativeMentions\", \"负向分\", \"负向次数\", \"负向倾向\"));\n List<String> scenarios = readStringList(firstNode(object, \"scenarios\", \"scenarioTags\", \"useCases\",\n \"contexts\", \"tags\", \"场景\", \"场景词\", \"适用场景\", \"标签\"));\n String note = firstText(object, \"analysisNote\", \"note\", \"reason\", \"reasoning\", \"summary\",\n \"comment\", \"分析说明\", \"说明\", \"理由\", \"原因\", \"摘要\");\n if (!hasText(note)) {\n note = averageRank == null\n ? \"分析模型未识别到明确排序\"\n : \"分析模型识别到推荐排序约为第 \" + averageRank + \" 名\";\n }\n metrics.add(new BrandMetric(name, mentionCount, mentionRate, averageRank, positiveScore, negativeScore,\n scenarios, note, \"LLM_STRUCTURED\"));\n }\n if (metrics.isEmpty()) {\n return Optional.empty();\n }\n\n String suggestion = firstTextDeep(root, \"suggestion\", \"advice\", \"recommendation\", \"finalRecommendation\",\n \"finalSuggestion\", \"conclusion\", \"建议\", \"最终建议\", \"购买建议\", \"GEO建议\", \"优化建议\");\n String shortSummary = firstTextDeep(root, \"shortSummary\", \"summary\", \"finalSummary\", \"brief\",\n \"oneSentenceSummary\", \"简要总结\", \"最终总结\", \"总结\", \"一句话总结\");\n if (!hasText(suggestion)) {\n suggestion = buildFallbackSuggestion(metrics);\n }\n if (!hasText(shortSummary)) {\n shortSummary = buildFallbackSummary(metrics);\n }\n return Optional.of(new AnalyzerOutput(metrics, suggestion, shortSummary));\n } catch (Exception e) {\n return Optional.empty();\n }\n }\n\n Optional<AnalyzerOutput> parsePartialAnalyzerJson(String content) {\n if (!hasText(content)) {\n return Optional.empty();\n }\n int objectsField = content.indexOf(\"\\\"objects\\\"\");\n int arrayStart = objectsField < 0 ? -1 : content.indexOf('[', objectsField);\n if (arrayStart < 0) {\n return Optional.empty();\n }\n\n ArrayNode completedObjects = objectMapper.createArrayNode();\n int cursor = arrayStart + 1;\n while (cursor < content.length()) {\n int objectStart = content.indexOf('{', cursor);\n if (objectStart < 0) {\n break;\n }\n String objectJson = extractBalancedJson(content.substring(objectStart));\n if (!hasText(objectJson)) {\n break;\n }\n try {\n JsonNode object = objectMapper.readTree(repairCommonJson(objectJson));\n if (object.isObject()) {\n completedObjects.add(object);\n }\n } catch (Exception ignored) {\n break;\n }\n cursor = objectStart + objectJson.length();\n }\n if (completedObjects.isEmpty()) {\n return Optional.empty();\n }\n\n ObjectNode salvaged = objectMapper.createObjectNode();\n salvaged.set(\"objects\", completedObjects);\n try {\n return parseAnalyzerJson(objectMapper.writeValueAsString(salvaged));\n } catch (Exception ignored) {\n return Optional.empty();\n }\n }\n\n Optional<ImplementationPlan> parseImplementationPlanJson(String content) {\n if (!hasText(content)) {\n return Optional.empty();\n }\n try {\n JsonNode root = readAnalyzerJson(content);\n String title = firstTextDeep(root, \"title\", \"planTitle\", \"方案标题\", \"标题\");\n String diagnosis = firstTextDeep(root, \"diagnosis\", \"finding\", \"诊断\", \"核心判断\", \"问题判断\");\n List<String> actions = readStringList(firstNode(root, \"actions\", \"steps\", \"行动\", \"执行动作\", \"执行步骤\"));\n List<String> wording = readStringList(firstNode(root, \"wordingRecommendations\", \"wording\", \"描述词方案\",\n \"文案修改\", \"表述建议\"));\n List<String> keywords = readStringList(firstNode(root, \"keywordRecommendations\", \"keywords\", \"关键词方案\",\n \"关键词建议\"));\n List<String> validation = readStringList(firstNode(root, \"validationSteps\", \"validation\", \"验证步骤\",\n \"复测标准\"));\n if (!hasText(title) || !hasText(diagnosis) || actions.isEmpty()) {\n return Optional.empty();\n }\n return Optional.of(new ImplementationPlan(title, diagnosis, actions, wording, keywords, validation));\n } catch (Exception ignored) {\n return Optional.empty();\n }\n }\n\n private JsonNode readAnalyzerJson(String content) throws Exception {\n String extracted = extractJson(content);\n try {\n return objectMapper.readTree(extracted);\n } catch (Exception firstParseError) {\n return objectMapper.readTree(repairCommonJson(extracted));\n }\n }\n\n private String analyzerSystemPrompt() {\n return \"\"\"\n 你是一个 GEO 多模型回答分析器,只负责分析已有模型回答,不直接重新回答用户原问题。\n 你必须只输出合法 JSON,不要输出 Markdown、解释、代码块或额外文本。\n 分析要客观,不能编造模型回答中没有出现的品牌、对象、排序和理由。\n objects 只能包含可被用户比较或决策的实体:品牌、产品、机构、地点、App、车型、游戏、课程或明确服务类型。\n “人员核验”“换人规则”“法律与保险”“技能达标”等维度、条件、风险、栏目标题不能作为对象;这些内容应写入 scenarios 或 analysisNote。\n 如果没有明确排序,averageRank 使用 null。\n 普通消费者身份必须给出明确的 recommendedObject、推荐原因、适用条件和备选方案,并写入 suggestion 和 shortSummary。\n 品牌方或运营身份应强调 GEO 内容缺口、竞品差异和可执行内容建议。\n JSON 顶层必须包含 objects、suggestion、shortSummary。\n \"\"\";\n }\n\n private String analyzerUserPrompt(AnalysisRequest request, List<ModelAnswer> answers) {\n String target = hasText(request.targetBrand()) ? request.targetBrand() : \"未指定\";\n String competitors = request.competitors() == null || request.competitors().isEmpty()\n ? \"未指定\"\n : String.join(\"、\", request.competitors());\n String brandFacts = hasText(request.brandFacts()) ? request.brandFacts() : \"未提供\";\n StringBuilder builder = new StringBuilder();\n builder.append(\"用户问题:\").append(request.question()).append('\\n');\n builder.append(\"用户身份:\").append(request.userRole().label()).append('\\n');\n builder.append(\"目标品牌或关注对象:\").append(target).append('\\n');\n builder.append(\"竞品:\").append(competitors).append(\"\\n\\n\");\n builder.append(\"目标品牌事实或优势资料:\").append(brandFacts).append(\"\\n\\n\");\n builder.append(\"模型回答:\\n\");\n for (ModelAnswer answer : answers) {\n builder.append(\"【\").append(answer.modelName()).append(\" / \").append(answer.mode()).append(\" / success=\")\n .append(answer.success()).append(\"】\\n\")\n .append(answer.answerText() == null ? \"\" : answer.answerText()).append(\"\\n\\n\");\n }\n builder.append(\"\"\"\n 请输出以下 JSON 结构:\n {\n \"objects\": [\n {\n \"name\": \"品牌或候选对象名称\",\n \"canonicalName\": \"同义表述合并后的标准名称,可省略\",\n \"entityType\": \"brand|product|institution|place|app|vehicle|game|course|service_type\",\n \"mentionCount\": 0,\n \"mentionRate\": 0.0,\n \"averageRank\": null,\n \"positiveScore\": 0,\n \"negativeScore\": 0,\n \"scenarios\": [\"场景词\"],\n \"analysisNote\": \"一句话说明\"\n }\n ],\n \"suggestion\": \"面向当前身份的详细建议。普通消费者必须包含推荐对象、推荐原因、适用条件、备选方案;品牌方/运营必须包含 GEO 内容优化建议。\",\n \"shortSummary\": \"一段简短最终建议,必须与当前身份匹配。\"\n }\n mentionRate = 提及该对象的成功模型回答数量 / 成功模型回答总数。\n positiveScore/negativeScore 使用 0 到 10 的整数,表示回答中的正负向表达强度。\n objects 最多输出 8 个,优先输出真实品牌名、产品名、机构名、城市/地点名、课程名、App 名或明确服务类型。\n 如果回答中只出现评估维度、风险项或要求项,而没有品牌/产品/机构等实体,objects 输出空数组。\n suggestion 和 shortSummary 各自不超过 300 个中文字符,避免输出被截断。\n 不要把“理由”“结论”“小提示”“高口碑品牌”“性价比选择”“高端选择”“仅作为协作文档工具”等说明性短语作为对象。\n \"\"\");\n return builder.toString();\n }\n\n private String strictAnalyzerSystemPrompt() {\n return \"\"\"\n 你是严格 JSON 提取器。只从给定回答中提取真实候选对象并统计,禁止输出解释。\n 只输出一个合法 JSON 对象。不要 Markdown,不要代码块。\n 候选对象必须是具体品牌、产品、机构、城市、App、车型、游戏、课程或服务名称。\n 过滤说明性短语、栏目标题、结论、理由、预算、用途、场景、优点、缺点、风险项、合同条款和评估维度。\n 对象分类写入 entityType,不要把 type/category/类型字段当对象名。\n \"\"\";\n }\n\n private String implementationPlanSystemPrompt(AnalysisRequest request) {\n String roleRule = switch (request.userRole()) {\n case BRAND_EMPLOYEE, MARKETER -> \"必须指出目标对象在 GEO 内容上的具体不足,并给出可直接采用的描述句式、关键词组合、页面修改动作和复测标准。\";\n case CONSUMER -> \"必须给出普通消费者可按顺序执行的决策方案,包括候选筛选、官网核验、第三方核验、试用或签约检查、风险退出条件。\";\n case RESEARCHER -> \"必须给出证据复核、编码、交叉验证和重复采样方案。\";\n };\n return \"\"\"\n 你是专业咨询项目经理。基于已经完成的多模型统计和外部网页证据生成实施方案,不要重新抽取候选对象。\n 只输出合法 JSON,不要 Markdown、代码块或解释。不得把搜索摘要当成已证实事实;PAGE_CONTENT 可作为已打开页面内容,SEARCH_SNIPPET 仅作为待核验线索。\n %s\n JSON 必须包含 title、diagnosis、actions、wordingRecommendations、keywordRecommendations、validationSteps。\n 每个数组最多 6 项,每项必须具体、可执行、可验证。\n \"\"\".formatted(roleRule);\n }\n\n private String implementationPlanUserPrompt(AnalysisRequest request, List<ModelAnswer> answers,\n List<BrandMetric> metrics, List<WebEvidence> evidence) {\n StringBuilder builder = new StringBuilder();\n builder.append(\"用户问题:\").append(request.question()).append('\\n');\n builder.append(\"用户身份:\").append(request.userRole().label()).append('\\n');\n builder.append(\"目标对象:\").append(hasText(request.targetBrand()) ? request.targetBrand() : \"未指定\").append('\\n');\n builder.append(\"竞品:\").append(request.competitors() == null ? \"\" : String.join(\"、\", request.competitors())).append(\"\\n\\n\");\n builder.append(\"对象统计:\\n\");\n for (BrandMetric metric : metrics.stream().limit(8).toList()) {\n builder.append(\"- \").append(metric.brandName())\n .append(\";提及=\").append(metric.mentionCount())\n .append(\";提及率=\").append(metric.mentionRate())\n .append(\";排序=\").append(metric.averageRank())\n .append(\";场景=\").append(metric.scenarioTags()).append('\\n');\n }\n builder.append(\"\\n外部证据:\\n\");\n for (WebEvidence item : evidence.stream().limit(12).toList()) {\n builder.append(\"- [\").append(item.sourceType()).append('/').append(item.contentDepth()).append(\"] \")\n .append(item.title()).append(\" | \").append(item.url()).append(\" | \")\n .append(item.excerpt()).append('\\n');\n }\n builder.append(\"\\n成功网页模型回答数:\")\n .append(answers.stream().filter(ModelAnswer::success).count()).append('\\n');\n builder.append(\"\"\"\n 输出:\n {\n \"title\": \"面向当前身份的方案名称\",\n \"diagnosis\": \"基于模型统计和外部证据的核心问题判断\",\n \"actions\": [\"按优先级排列的执行动作\"],\n \"wordingRecommendations\": [\"可直接使用或修改的描述句式;消费者可为空数组\"],\n \"keywordRecommendations\": [\"具体关键词或检索词组合\"],\n \"validationSteps\": [\"可量化的核验或复测动作\"]\n }\n \"\"\");\n return builder.toString();\n }\n\n private String strictAnalyzerUserPrompt(AnalysisRequest request, List<ModelAnswer> answers) {\n StringBuilder builder = new StringBuilder();\n builder.append(\"问题:\").append(request.question()).append('\\n');\n builder.append(\"身份:\").append(request.userRole().label()).append('\\n');\n builder.append(\"目标:\").append(hasText(request.targetBrand()) ? request.targetBrand() : \"未填写\").append('\\n');\n builder.append(\"竞品:\")\n .append(request.competitors() == null || request.competitors().isEmpty()\n ? \"未填写\"\n : String.join(\"、\", request.competitors()))\n .append(\"\\n\\n\");\n for (ModelAnswer answer : answers) {\n builder.append(answer.modelName()).append(\":\\n\")\n .append(answer.answerText() == null ? \"\" : answer.answerText()).append(\"\\n\\n\");\n }\n builder.append(\"\"\"\n 返回 JSON:\n {\n \"objects\": [\n {\n \"name\": \"具体候选对象\",\n \"canonicalName\": \"合并别名后的标准名\",\n \"entityType\": \"brand|product|institution|place|app|vehicle|game|course|service_type\",\n \"mentionCount\": 1,\n \"mentionRate\": 0.5,\n \"averageRank\": null,\n \"positiveScore\": 0,\n \"negativeScore\": 0,\n \"scenarios\": [],\n \"analysisNote\": \"基于回答的简短说明\"\n }\n ],\n \"suggestion\": \"给当前身份的建议\",\n \"shortSummary\": \"最终建议\"\n }\n 如果同一对象有别名或产品线表述,使用 canonicalName 合并。\n \"\"\");\n return builder.toString();\n }\n\n private JsonNode firstArray(JsonNode node, String... names) {\n if (node.isArray() && looksLikeMetricArray(node)) {\n return node;\n }\n for (String name : names) {\n JsonNode value = node.path(name);\n if (value.isArray() && looksLikeMetricArray(value)) {\n return value;\n }\n if (value.isObject()) {\n JsonNode converted = objectValuesAsArray(value);\n if (converted.isArray() && looksLikeMetricArray(converted)) {\n return converted;\n }\n }\n }\n JsonNode nested = firstMetricArrayDeep(node);\n if (nested.isArray()) {\n return nested;\n }\n return objectMapper.createArrayNode();\n }\n\n private JsonNode firstMetricArrayDeep(JsonNode node) {\n if (node == null || node.isNull() || node.isMissingNode()) {\n return objectMapper.nullNode();\n }\n if (node.isArray() && looksLikeMetricArray(node)) {\n return node;\n }\n if (node.isObject()) {\n JsonNode converted = objectValuesAsArray(node);\n if (converted.isArray() && looksLikeMetricArray(converted)) {\n return converted;\n }\n var fields = node.fields();\n while (fields.hasNext()) {\n JsonNode found = firstMetricArrayDeep(fields.next().getValue());\n if (found.isArray()) {\n return found;\n }\n }\n }\n return objectMapper.nullNode();\n }\n\n private boolean looksLikeMetricArray(JsonNode node) {\n if (!node.isArray() || node.isEmpty()) {\n return false;\n }\n for (JsonNode item : node) {\n if (item.isObject() && hasText(firstEntityName(item))) {\n return true;\n }\n if (item.isTextual() && hasText(item.asText())) {\n return true;\n }\n }\n return false;\n }\n\n private JsonNode objectValuesAsArray(JsonNode node) {\n var array = objectMapper.createArrayNode();\n if (node == null || !node.isObject()) {\n return array;\n }\n var fields = node.fields();\n while (fields.hasNext()) {\n var field = fields.next();\n JsonNode value = field.getValue();\n if (value.isObject()) {\n if (!looksLikeMetricObject(value)) {\n continue;\n }\n var object = value.deepCopy();\n if (!hasText(firstEntityName(object))) {\n ((com.fasterxml.jackson.databind.node.ObjectNode) object).put(\"name\", field.getKey());\n }\n array.add(object);\n } else if (value.isTextual() && hasText(value.asText())) {\n var object = objectMapper.createObjectNode();\n object.put(\"name\", value.asText());\n array.add(object);\n }\n }\n return array;\n }\n\n private boolean looksLikeMetricObject(JsonNode node) {\n if (node == null || !node.isObject()) {\n return false;\n }\n if (hasText(firstEntityName(node))) {\n return true;\n }\n return !firstNode(node, \"mentionCount\", \"mentions\", \"count\", \"frequency\", \"mention_count\", \"提及次数\",\n \"提及数\", \"出现次数\", \"mentionRate\", \"mention_rate\", \"visibility\", \"exposureRate\", \"提及率\",\n \"可见度\", \"曝光率\", \"averageRank\", \"rank\", \"recommendRank\", \"ranking\", \"position\",\n \"平均排序\", \"推荐排序\", \"排序\", \"排名\", \"positiveScore\", \"positive\", \"positive_count\",\n \"positiveMentions\", \"sentimentScore\", \"正向分\", \"正向次数\", \"正向倾向\", \"negativeScore\",\n \"negative\", \"negative_count\", \"negativeMentions\", \"负向分\", \"负向次数\", \"负向倾向\",\n \"scenarios\", \"scenarioTags\", \"useCases\", \"contexts\", \"tags\", \"场景\", \"场景词\", \"适用场景\",\n \"标签\").isNull();\n }\n\n private JsonNode firstNode(JsonNode node, String... names) {\n for (String name : names) {\n JsonNode value = node.path(name);\n if (!value.isMissingNode() && !value.isNull()) {\n return value;\n }\n }\n return objectMapper.nullNode();\n }\n\n private String firstEntityName(JsonNode node) {\n return firstText(node, \"canonicalName\", \"name\", \"brandName\", \"objectName\", \"productName\",\n \"institutionName\", \"serviceName\", \"courseName\", \"appName\", \"placeName\", \"vehicleName\",\n \"gameName\", \"entity\", \"entityName\", \"title\", \"candidate\", \"recommendedObject\", \"standardName\",\n \"名称\", \"标准名称\", \"displayName\", \"label\", \"对象\", \"品牌\", \"产品\", \"机构\", \"地点\", \"城市\",\n \"课程\", \"应用\", \"App\", \"候选对象\", \"推荐对象\");\n }\n\n private String firstText(JsonNode node, String... names) {\n for (String name : names) {\n JsonNode value = node.path(name);\n if (value.isTextual() && hasText(value.asText())) {\n return value.asText();\n }\n if (value.isNumber() || value.isBoolean()) {\n return value.asText();\n }\n }\n return \"\";\n }\n\n private String firstTextDeep(JsonNode node, String... names) {\n String direct = firstText(node, names);\n if (hasText(direct)) {\n return direct;\n }\n if (node == null || node.isNull() || node.isMissingNode()) {\n return \"\";\n }\n if (node.isObject()) {\n var fields = node.fields();\n while (fields.hasNext()) {\n String found = firstTextDeep(fields.next().getValue(), names);\n if (hasText(found)) {\n return found;\n }\n }\n }\n if (node.isArray()) {\n for (JsonNode item : node) {\n String found = firstTextDeep(item, names);\n if (hasText(found)) {\n return found;\n }\n }\n }\n return \"\";\n }\n\n private String cleanObjectName(String name) {\n if (!hasText(name)) {\n return \"\";\n }\n String normalized = name.replaceAll(\"\\\\s+\", \" \").trim();\n if (looksLikeEntityName(normalized)) {\n return normalized;\n }\n String extracted = extractKnownServiceType(normalized);\n return hasText(extracted) ? extracted : \"\";\n }\n\n private boolean looksLikeEntityName(String value) {\n if (value.length() > 28) {\n return false;\n }\n long punctuationCount = value.chars()\n .filter(ch -> \",。;:,.!?!?;、\".indexOf(ch) >= 0)\n .count();\n if (punctuationCount >= 2) {\n return false;\n }\n String lower = value.toLowerCase();\n if (lower.contains(\"brandtest\") || value.matches(\"(?i).*[A-Za-z]+Test.*\")) {\n return false;\n }\n if (isTrainingDescriptorName(value)) {\n return false;\n }\n return List.of(\"建议\", \"由于\", \"因为\", \"根据\", \"可以\", \"需要\", \"适合\", \"优先\", \"其次\", \"最后\", \"如下\", \"如果\",\n \"用户\", \"痛点\", \"预算\", \"条件\", \"限制\", \"需求\", \"关键词\", \"头部\", \"对象\", \"候选\", \"品牌方\", \"董事长\", \"高管\", \"测试\",\n \"年龄范围\", \"预算范围\", \"上课方式\", \"课程内容\", \"品牌偏好\")\n .stream().noneMatch(value::contains);\n }\n\n private boolean isTrainingDescriptorName(String value) {\n return value.matches(\"20\\\\d{2}年.*\")\n || value.startsWith(\"对\")\n || value.startsWith(\"重点\")\n || value.startsWith(\"获\")\n || List.of(\"少儿编程教育\", \"编程教育\", \"国际教育\", \"老师的引导方式\", \"引导方式\")\n .stream().anyMatch(value::contains);\n }\n\n private String extractKnownServiceType(String value) {\n List<String> knownTypes = List.of(\n \"公立口腔专科医院/综合医院口腔科\",\n \"公立口腔专科医院\",\n \"综合医院口腔科\",\n \"公立医院口腔科\",\n \"公立三甲医院口腔科\",\n \"大型私立连锁齿科\",\n \"私立连锁口腔机构\",\n \"合规连锁私立口腔机构\",\n \"社区或普通私立诊所\",\n \"正规口腔医院\",\n \"正规连锁诊所\",\n \"连锁诊所\",\n \"专科口腔医院\",\n \"口腔专科医院\",\n \"社区诊所\",\n \"普通私立诊所\",\n \"正畸专科诊所\",\n \"种植专科诊所\"\n );\n return knownTypes.stream()\n .filter(value::contains)\n .findFirst()\n .orElse(\"\");\n }\n\n private int firstInt(JsonNode node, int defaultValue, String... names) {\n JsonNode value = firstNode(node, names);\n return value.isNumber() || value.isTextual() ? value.asInt(defaultValue) : defaultValue;\n }\n\n private double firstDouble(JsonNode node, double defaultValue, String... names) {\n JsonNode value = firstNode(node, names);\n return value.isNumber() || value.isTextual() ? value.asDouble(defaultValue) : defaultValue;\n }\n\n private String buildFallbackSuggestion(List<BrandMetric> metrics) {\n BrandMetric top = topMetric(metrics);\n if (top == null) {\n return \"当前结构化分析未生成详细建议,请结合模型原始回答和指标表进一步判断。\";\n }\n return \"综合多模型回答,建议优先关注\" + top.brandName()\n + \"。它在回答中的提及或正向描述更突出,最终仍应结合预算、使用场景、口碑和售后保障确认。\";\n }\n\n private String buildFallbackSummary(List<BrandMetric> metrics) {\n BrandMetric top = topMetric(metrics);\n if (top == null) {\n return \"当前未识别到足够稳定的候选对象。\";\n }\n return \"综合多模型回答,\" + top.brandName() + \"更值得优先关注。\";\n }\n\n private BrandMetric topMetric(List<BrandMetric> metrics) {\n return metrics.stream()\n .max((left, right) -> {\n int mentionCompare = Integer.compare(left.mentionCount(), right.mentionCount());\n if (mentionCompare != 0) {\n return mentionCompare;\n }\n return Integer.compare(left.positiveScore(), right.positiveScore());\n })\n .orElse(null);\n }\n\n private List<String> readStringList(JsonNode node) {\n if (!node.isArray()) {\n return List.of();\n }\n List<String> values = new ArrayList<>();\n for (JsonNode item : node) {\n String value = item.asText(\"\");\n if (hasText(value)) {\n values.add(value);\n }\n }\n return values.stream().distinct().limit(10).toList();\n }\n\n private String extractJson(String content) {\n String trimmed = content.trim();\n String balanced = extractBalancedJson(trimmed);\n if (hasText(balanced)) {\n return balanced;\n }\n int firstBrace = trimmed.indexOf('{');\n int lastBrace = trimmed.lastIndexOf('}');\n if (firstBrace >= 0 && lastBrace > firstBrace) {\n return trimmed.substring(firstBrace, lastBrace + 1);\n }\n return trimmed;\n }\n\n private String extractBalancedJson(String content) {\n int start = content.indexOf('{');\n if (start < 0) {\n return \"\";\n }\n boolean inString = false;\n boolean escaped = false;\n int depth = 0;\n for (int i = start; i < content.length(); i++) {\n char ch = content.charAt(i);\n if (escaped) {\n escaped = false;\n continue;\n }\n if (ch == '\\\\' && inString) {\n escaped = true;\n continue;\n }\n if (ch == '\"') {\n inString = !inString;\n continue;\n }\n if (inString) {\n continue;\n }\n if (ch == '{') {\n depth++;\n } else if (ch == '}') {\n depth--;\n if (depth == 0) {\n return content.substring(start, i + 1);\n }\n }\n }\n return \"\";\n }\n\n private String repairCommonJson(String json) {\n return json\n .replace('“', '\"')\n .replace('”', '\"')\n .replace('‘', '\\'')\n .replace('’', '\\'')\n .replaceAll(\"(?m)([\\\\{,]\\\\s*)([A-Za-z][A-Za-z0-9_]*)(\\\\s*:)\", \"$1\\\"$2\\\"$3\")\n .replaceAll(\"'([^'\\\\\\\\]*(?:\\\\\\\\.[^'\\\\\\\\]*)*)'\", \"\\\"$1\\\"\")\n .replaceAll(\",\\\\s*([}\\\\]])\", \"$1\");\n }\n\n private double clampRate(double value) {\n if (value < 0) {\n return 0;\n }\n if (value > 1) {\n return 1;\n }\n return Math.round(value * 100.0) / 100.0;\n }\n\n private boolean hasDeepSeekConfig() {\n return hasText(deepseekKey) && hasText(deepseekUrl) && hasText(deepseekModel);\n }\n\n private String normalizeChatCompletionsUrl(String apiUrl) {\n String normalized = apiUrl.endsWith(\"/\") ? apiUrl.substring(0, apiUrl.length() - 1) : apiUrl;\n if (normalized.endsWith(\"/chat/completions\")) {\n return normalized;\n }\n return normalized + \"/chat/completions\";\n }\n\n private String responseShape(String response) {\n if (!hasText(response)) {\n return \"empty\";\n }\n try {\n JsonNode root = objectMapper.readTree(response);\n List<String> keys = new ArrayList<>();\n root.fieldNames().forEachRemaining(keys::add);\n return keys.toString();\n } catch (Exception e) {\n return \"non-json response\";\n }\n }\n\n private String analyzerContentShape(String content) {\n if (!hasText(content)) {\n return \"Analyzer content is empty.\";\n }\n try {\n JsonNode root = readAnalyzerJson(content);\n List<String> keys = new ArrayList<>();\n if (root.isObject()) {\n root.fieldNames().forEachRemaining(keys::add);\n return \"Analyzer JSON top-level keys: \" + keys + \".\";\n }\n return \"Analyzer JSON root type: \" + root.getNodeType() + \".\";\n } catch (Exception e) {\n String compact = content.replaceAll(\"\\\\s+\", \" \").trim();\n if (compact.length() > 160) {\n compact = compact.substring(0, 160) + \"...\";\n }\n return \"Analyzer content is not parseable JSON. Preview: \" + compact;\n }\n }\n\n private boolean hasText(String value) {\n return value != null && !value.isBlank();\n }\n\n private String safeMessage(Exception e) {\n String message = e.getMessage();\n if (!hasText(message)) {\n message = e.getClass().getSimpleName();\n }\n return message.length() > 180 ? message.substring(0, 180) + \"...\" : message;\n }\n\n public record AnalyzerAttempt(\n Optional<AnalyzerOutput> output,\n String message\n ) {\n }\n\n public record AnalyzerOutput(\n List<BrandMetric> metrics,\n String suggestion,\n String shortSummary\n ) {\n }\n}\n"}}, "versions/2026-07-19-webpage-annotation-crawl-fix-latest/backend/src/main/resources/application.yml": {"bd363d90e647f97f90e1507abcc387352a5ced48": {"t": 1786068433.6380768, "n": 6291, "h": "a9be402a7e9411c5", "c": "server:\n port: 8080\n\nspring:\n datasource:\n url: ${MYSQL_URL:jdbc:mysql://localhost:3306/geo_analysis?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true}\n username: ${MYSQL_USERNAME:root}\n password: ${MYSQL_PASSWORD:123456}\n driver-class-name: com.mysql.cj.jdbc.Driver\n\ngeo:\n evidence:\n enabled: ${GEO_EVIDENCE_ENABLED:true}\n max-results: ${GEO_EVIDENCE_MAX_RESULTS:12}\n timeout-seconds: ${GEO_EVIDENCE_TIMEOUT_SECONDS:8}\n deepseek:\n api-key: ${DEEPSEEK_API_KEY:}\n api-url: ${DEEPSEEK_API_URL:https://api.deepseek.com/chat/completions}\n model: ${DEEPSEEK_MODEL:deepseek-v4-flash}\n doubao:\n api-key: ${DOUBAO_API_KEY:}\n api-url: ${DOUBAO_API_URL:https://ark.cn-beijing.volces.com/api/v3}\n model: ${DOUBAO_MODEL:doubao-seed-2-1-pro-260628}\n web:\n playwright:\n node-command: ${WEB_PLAYWRIGHT_NODE_COMMAND:node}\n script-path: ${WEB_PLAYWRIGHT_SCRIPT_PATH:browser/playwright-runner.mjs}\n user-data-dir: ${WEB_PLAYWRIGHT_USER_DATA_DIR:${user.home}/.geo-browser}\n artifact-dir: ${WEB_PLAYWRIGHT_ARTIFACT_DIR:browser-diagnostics}\n headless: ${WEB_PLAYWRIGHT_HEADLESS:false}\n timeout-seconds: ${WEB_PLAYWRIGHT_TIMEOUT_SECONDS:420}\n verification-hold-seconds: ${WEB_PLAYWRIGHT_VERIFICATION_HOLD_SECONDS:120}\n login-wait-seconds: ${WEB_PLAYWRIGHT_LOGIN_WAIT_SECONDS:45}\n manual-login-wait-seconds: ${WEB_PLAYWRIGHT_MANUAL_LOGIN_WAIT_SECONDS:300}\n answer-wait-seconds: ${WEB_PLAYWRIGHT_ANSWER_WAIT_SECONDS:180}\n answer-stable-seconds: ${WEB_PLAYWRIGHT_ANSWER_STABLE_SECONDS:12}\n providers:\n deepseek:\n enabled: ${DEEPSEEK_WEB_ENABLED:false}\n login-url: ${DEEPSEEK_WEB_LOGIN_URL:https://chat.deepseek.com}\n selectors:\n username: ${DEEPSEEK_WEB_SELECTOR_USERNAME:input[type='tel']|input[type='text']}\n password: ${DEEPSEEK_WEB_SELECTOR_PASSWORD:input[type='password']}\n verification-code: ${DEEPSEEK_WEB_SELECTOR_CODE:input[autocomplete='one-time-code']|input[placeholder*='验证码']|input[name*='code' i]|input[id*='code' i]|input[type='tel']}\n request-code-button: ${DEEPSEEK_WEB_SELECTOR_REQUEST_CODE:button:has-text('验证码')|button:has-text('获取')|[role='button']:has-text('验证码')|[role='button']:has-text('发送验证码')|text=发送验证码}\n submit-login-button: ${DEEPSEEK_WEB_SELECTOR_SUBMIT_LOGIN:button:has-text('登录')|button[type='submit']|[role='button']:has-text('登录')|text=登录}\n mode-menu-button: ${DEEPSEEK_WEB_SELECTOR_MODE_MENU:button:has-text('快速模式')|[role='button']:has-text('快速模式')}\n quick-mode-button: ${DEEPSEEK_WEB_SELECTOR_QUICK_MODE:button:has-text('快速模式')|[role='menuitem']:has-text('快速模式')|[role='option']:has-text('快速模式')|text=快速模式}\n chat-input: ${DEEPSEEK_WEB_SELECTOR_CHAT_INPUT:textarea|[contenteditable='true']}\n send-button: ${DEEPSEEK_WEB_SELECTOR_SEND:button:has-text('发送')|button[aria-label*='send' i]}\n answer: ${DEEPSEEK_WEB_SELECTOR_ANSWER:.ds-markdown|.markdown|[data-message-author-role='assistant']|[class*='markdown']|[class*='answer']}\n doubao:\n enabled: ${DOUBAO_WEB_ENABLED:true}\n login-url: ${DOUBAO_WEB_LOGIN_URL:https://www.doubao.com/chat}\n selectors:\n username: ${DOUBAO_WEB_SELECTOR_USERNAME:input[placeholder*='手机号']|input[type='tel']|input[inputmode='numeric']|input[type='text']}\n password: ${DOUBAO_WEB_SELECTOR_PASSWORD:input[type='password']}\n verification-code: ${DOUBAO_WEB_SELECTOR_CODE:input[autocomplete='one-time-code']|input[placeholder*='验证码']|input[type='tel']|input[inputmode='numeric']}\n request-code-button: ${DOUBAO_WEB_SELECTOR_REQUEST_CODE:button:has-text('验证码')|button:has-text('获取')|button:has-text('发送验证码')|[role='button']:has-text('验证码')|[role='button']:has-text('获取')|text=发送验证码}\n submit-login-button: ${DOUBAO_WEB_SELECTOR_SUBMIT_LOGIN:button:has-text('下一步')|[role='button']:has-text('下一步')|button:has-text('登录')|[role='button']:has-text('登录')|button:has-text('抖音一键登录')|button:has-text('飞书企业账号登录')|button[type='submit']}\n chat-input: ${DOUBAO_WEB_SELECTOR_CHAT_INPUT:textarea|[contenteditable='true']|[data-testid*='chat_input']|[class*='chat-input']|[class*='input-area']}\n send-button: ${DOUBAO_WEB_SELECTOR_SEND:button:has-text('发送')|button[aria-label*='send' i]|button[aria-label*='发送']|[data-testid*='send']|button[class*='send']}\n answer: ${DOUBAO_WEB_SELECTOR_ANSWER:.markdown|[data-testid*='message']|[data-testid*='conversation']|[data-testid*='bot']|[data-testid*='reply']|[class*='answer']|[class*='markdown']|[class*='assistant']|[class*='message']|[class*='bubble']|[class*='reply']}\n kimi:\n enabled: ${KIMI_WEB_ENABLED:true}\n login-url: ${KIMI_WEB_LOGIN_URL:https://www.kimi.com/chat|https://www.kimi.com/|https://kimi.moonshot.cn/}\n selectors:\n username: ${KIMI_WEB_SELECTOR_USERNAME:input[type='tel']|input[type='text']|input[placeholder*='手机号']|input[placeholder*='邮箱']}\n password: ${KIMI_WEB_SELECTOR_PASSWORD:input[type='password']}\n verification-code: ${KIMI_WEB_SELECTOR_CODE:input[autocomplete='one-time-code']|input[placeholder*='验证码']|input[name*='code' i]|input[id*='code' i]|input[type='tel']}\n request-code-button: ${KIMI_WEB_SELECTOR_REQUEST_CODE:button:has-text('验证码')|button:has-text('获取')|button:has-text('发送验证码')|[role='button']:has-text('验证码')|[role='button']:has-text('发送验证码')|text=发送验证码}\n submit-login-button: ${KIMI_WEB_SELECTOR_SUBMIT_LOGIN:button:has-text('登录')|button[type='submit']|button:has-text('继续')|[role='button']:has-text('登录')|[role='button']:has-text('继续')|text=登录|text=继续}\n chat-input: ${KIMI_WEB_SELECTOR_CHAT_INPUT:textarea|[contenteditable='true']|[class*='chat-input']|[class*='input']|[data-testid*='chat']}\n send-button: ${KIMI_WEB_SELECTOR_SEND:button:has-text('发送')|button:has-text('↑')|button[aria-label*='send' i]|button[aria-label*='发送']|button[aria-label*='发送消息']|button[aria-label*='提交']|button[class*='send']|[data-testid*='send']}\n answer: ${KIMI_WEB_SELECTOR_ANSWER:.markdown|[class*='segment']|[class*='answer']|[class*='markdown']|[class*='assistant']|[data-testid*='message']}\n"}}}} |