| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941 |
- package com.etotem.cfc.service;
- import com.etotem.cfc.common.Result;
- import com.etotem.cfc.dto.DanReportUploadVO;
- import com.etotem.cfc.dto.ParsedReportPayload;
- import com.etotem.cfc.entity.*;
- import com.etotem.cfc.mapper.DanReportUploadMapper;
- import com.etotem.cfc.mapper.FamilyMemberMapper;
- import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
- import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
- import com.fasterxml.jackson.core.type.TypeReference;
- import com.fasterxml.jackson.databind.JsonNode;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import lombok.extern.slf4j.Slf4j;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.http.*;
- import org.springframework.stereotype.Service;
- import org.springframework.web.client.RestTemplate;
- import javax.annotation.Resource;
- import java.io.BufferedReader;
- import java.io.File;
- import java.io.FileInputStream;
- import java.io.InputStreamReader;
- import java.math.BigDecimal;
- import java.nio.charset.StandardCharsets;
- import java.text.SimpleDateFormat;
- import java.util.*;
- import java.util.concurrent.Executor;
- import java.util.concurrent.TimeUnit;
- import java.util.function.Consumer;
- /**
- * 报告异步采集服务:上传后进入后台队列,指纹判定类型后走专用脚本或 opencode,
- * 解析完成自动入库,前端通过草稿 parse_status 轮询。
- */
- @Slf4j
- @Service
- public class ReportCollectService {
- @Resource
- private ReportFingerprintService fingerprintService;
- @Resource
- private LangGraphReportParseService langGraphReportParseService;
- @Resource
- private HealthReportDraftService healthReportDraftService;
- @Resource
- private HealthReportService healthReportService;
- @Resource
- private FoodService foodService;
- @Resource
- private DimensionScoreService dimensionScoreService;
- @Resource
- private FoodRecommendService foodRecommendService;
- @Resource
- private ReportBlockService reportBlockService;
- @Resource
- private ReportBlockAssembler reportBlockAssembler;
- @Resource
- private StorageService storageService;
- @Resource
- private FamilyMemberMapper familyMemberMapper;
- @Resource
- private ReportTypeRegistryMapper reportTypeRegistryMapper;
- @Resource
- private DanReportUploadMapper danReportUploadMapper;
- @Resource
- private DanReportParseService danReportParseService;
- @Resource
- private DanReportUploadService danReportUploadService;
- @Resource(name = "taskExecutor")
- private Executor taskExecutor;
- @Resource
- private TaskService taskService;
- @Resource
- private RestTemplate restTemplate;
- @Resource
- private ObjectMapper objectMapper;
- @Value("${report.collect.opencode-base:http://127.0.0.1:4090}")
- private String opencodeBase;
- @Value("${report.collect.opencode-user:opencode}")
- private String opencodeUser;
- @Value("${report.collect.opencode-password:IwinTrue@123}")
- private String opencodePassword;
- @Value("${report.collect.opencode-workdir:/app/cfc}")
- private String opencodeWorkdir;
- @Value("${report.collect.opencode-model-id:agnes-2.5-flash}")
- private String opencodeModelId;
- @Value("${report.collect.opencode-model-provider:agnes-ai}")
- private String opencodeModelProvider;
- /** 常量:解析方式 */
- public static final String METHOD_FINGERPRINT = "fingerprint_script";
- public static final String METHOD_LANGGRAPH_SCRIPT = "langgraph_script";
- public static final String METHOD_LANGGRAPH_DETECT = "langgraph_detect";
- public static final String METHOD_OPENCODE = "opencode";
- /** 状态常量:与草稿 parse_status 对齐 */
- public static final String STATUS_COLLECTING = "collecting";
- public static final String STATUS_COMPLETED = "completed";
- public static final String STATUS_FAILED = "failed";
- /**
- * 提交异步采集任务。上传完成后调用,立即返回。
- */
- public void submitAsyncCollect(Long draftId) {
- try {
- taskExecutor.execute(() -> collect(draftId));
- } catch (Exception e) {
- log.error("提交异步采集任务失败 draftId={}", draftId, e);
- healthReportDraftService.markCollectFailed(draftId, "提交任务失败: " + e.getMessage());
- }
- }
- /**
- * 采集主流程:指纹判定 → 已知专用脚本 / 未知 opencode → 解析 JSON → 自动入库
- */
- public void collect(Long draftId) {
- HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
- if (draft == null) {
- log.warn("异步采集草稿不存在 draftId={}", draftId);
- return;
- }
- if (STATUS_COMPLETED.equals(draft.getParseStatus())) {
- return;
- }
- try {
- healthReportDraftService.markCollecting(draftId, null);
- String pdfPath = resolvePdfPath(draft);
- if (pdfPath == null) {
- healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
- return;
- }
- // 文件类型预检:采集链路(指纹引擎/LangGraph/opencode)仅支持 PDF,
- // 图片等非 PDF 文件直接标记失败,避免 PyPDF2 报 EOF marker not found 等错误
- if (!isPdfFile(pdfPath)) {
- log.warn("采集跳过非PDF文件 draftId={}, path={}, filename={}",
- draftId, pdfPath, draft.getOriginalFilename());
- healthReportDraftService.markCollectFailed(draftId, "仅支持PDF报告文件,请上传PDF格式");
- return;
- }
- // 指纹判定
- Map<String, Object> fp = fingerprintService.detect(pdfPath, draft.getOriginalFilename());
- String typeId = (String) fp.get("typeId");
- // DAN 家族分流:指纹识别到 dan_* 类型(如 dan_a2)→ Java 同步解析 + DAN 入库,
- // 不进入健康报告管道(parserPath 为空的 dan 类型指纹也不会走 LangGraph 脚本分支)
- if (typeId != null && !"unknown".equals(typeId)) {
- ReportTypeRegistry registry = reportTypeRegistryMapper.selectOne(
- new LambdaQueryWrapper<ReportTypeRegistry>()
- .eq(ReportTypeRegistry::getTypeId, typeId)
- .last("LIMIT 1"));
- if (registry != null && "dan".equals(registry.getFamily())) {
- collectDanReport(draft, pdfPath, registry);
- return;
- }
- }
- ParsedReportPayload.Payload payload;
- String method;
- if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
- // 指纹成功:脚本已迁移到 LangGraph(/report/parse,算法 + LLM 兜底)
- method = METHOD_LANGGRAPH_SCRIPT;
- payload = langGraphReportParseService.parseByScript(pdfPath);
- // LangGraph 脚本解析为空时回退 opencode,避免已知类型整单失败
- if (payload == null) {
- log.warn("已知类型 LangGraph 脚本解析为空,回退 opencode typeId={}", typeId);
- method = METHOD_OPENCODE;
- payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
- }
- } else {
- // 无指纹:先用 LangGraph 识别(detect-type → parse-typed / script),识别不了再 opencode
- method = METHOD_LANGGRAPH_DETECT;
- payload = langGraphReportParseService.parseWithDetect(pdfPath);
- if (payload == null) {
- log.warn("无指纹 LangGraph 识别/解析为空,回退 opencode typeId={}", typeId);
- method = METHOD_OPENCODE;
- payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
- }
- }
- if (payload == null) {
- healthReportDraftService.markCollectFailed(draftId, "解析结果为空或无效");
- return;
- }
- harmReportPayloadValid(payload);
- healthReportDraftService.updateParseMethod(draftId, method);
- healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
- insertReport(draft, payload);
- log.info("异步采集完成 draftId={}, method={}", draftId, method);
- } catch (Exception e) {
- log.error("异步采集失败 draftId={}", draftId, e);
- healthReportDraftService.markCollectFailed(draftId, e.getMessage());
- }
- }
- /**
- * DAN 家族报告采集:指纹识别到 dan_* 类型(如 dan_a2)后走 Java 同步解析 + DAN 入库。
- * 复用 DanReportUploadService.confirmUpload(@Transactional:result 入库 / 展示块 / 汇总 / 五维刷新 / 自动方案),
- * 完成草稿闭环:reportType 置为识别类型 + markCollectCompleted(reportId = dan upload id),
- * 前端 collect/status 据此跳转 dan-assessment/report-result?reportId=...
- */
- private void collectDanReport(HealthReportDraft draft, String pdfPath, ReportTypeRegistry registry) {
- Long draftId = draft.getId();
- Long memberId = draft.getMemberId();
- if (memberId == null) {
- healthReportDraftService.markCollectFailed(draftId,
- "已识别为" + registry.getDisplayName() + "报告,请重新上传并选择报告归属成员");
- return;
- }
- String dimension = resolveDanDimension(registry.getTypeId());
- try {
- // Java 同步解析(与 uploadAndParse 同一解析器)
- DanReportParseService.DanParsedReport parsed;
- try (FileInputStream fis = new FileInputStream(new File(pdfPath))) {
- parsed = danReportParseService.parse(fis, dimension);
- }
- if (parsed == null || parsed.getItems() == null || parsed.getItems().isEmpty()) {
- healthReportDraftService.markCollectFailed(draftId,
- "已识别为" + registry.getDisplayName() + "报告,但未能提取有效指标");
- return;
- }
- // 构建 DAN 上传记录(对齐 uploadAndParse 字段)
- DanReportUpload upload = new DanReportUpload();
- upload.setFamilyMemberId(memberId);
- upload.setUploaderUserId(draft.getUserId());
- upload.setDimension(dimension);
- Long familyId = draft.getFamilyId();
- if (familyId == null) {
- familyId = resolveDanFamilyId(memberId);
- }
- upload.setFamilyId(familyId);
- upload.setFileUrl(draft.getFileUrl());
- upload.setFileId(toShortFileId(draft.getFileUrl()));
- upload.setFileName(draft.getOriginalFilename());
- upload.setFileSize(new File(pdfPath).length());
- upload.setUploadedAt(new Date());
- upload.setDraftStatus(DanReportUpload.DRAFT_PENDING);
- upload.setParseStatus(DanReportUpload.PARSE_SUCCESS);
- upload.setParserVersion("v1");
- upload.setParsedItems(objectMapper.writeValueAsString(parsed.getItems()));
- upload.setParsedExtra(objectMapper.writeValueAsString(parsed.getExtra()));
- upload.setParsedSummary(parsed.getSummary());
- upload.setParsedSuggestions(parsed.getSuggestions());
- upload.setReportType(parsed.getReportType());
- upload.setParsedAt(new Date());
- upload.setEditCount(0);
- upload.setCreatedAt(new Date());
- upload.setUpdatedAt(new Date());
- danReportUploadMapper.insert(upload);
- // 自动确认入库(result + blocks + 汇总 + 五维 + 自动方案)
- Map<String, Object> extra = parsed.getExtra();
- String childName = extra != null && extra.get("name") != null ? String.valueOf(extra.get("name")) : "";
- String assessmentDate = extra != null && extra.get("reportDate") != null
- ? String.valueOf(extra.get("reportDate")) : "";
- Result<DanReportUploadVO> confirm = danReportUploadService.confirmUpload(
- upload.getId(), draft.getUserId(), childName, assessmentDate);
- if (confirm == null || confirm.getCode() == null || confirm.getCode() != 200) {
- healthReportDraftService.markCollectFailed(draftId,
- "DAN报告确认入库失败: " + (confirm != null ? confirm.getMessage() : "无响应"));
- return;
- }
- // 草稿闭环:reportType 置为识别类型(前端按 dan_* 跳转)+ completed
- healthReportDraftService.updateReportType(draftId, registry.getTypeId());
- healthReportDraftService.markCollectCompleted(draftId, upload.getId());
- log.info("DAN家族采集完成 draftId={}, typeId={}, danUploadId={}, dimension={}",
- draftId, registry.getTypeId(), upload.getId(), dimension);
- } catch (Exception e) {
- log.error("DAN家族采集失败 draftId={}, typeId={}", draftId, registry.getTypeId(), e);
- healthReportDraftService.markCollectFailed(draftId, e.getMessage());
- }
- }
- /** dan 家族 typeId → 测评维度:dan_a2 → mind、dan_b4 → wisdom 等;兼容 dan_<dim> 语义名,否则默认 mind */
- private String resolveDanDimension(String typeId) {
- if (typeId != null && typeId.startsWith("dan_")) {
- String code = typeId.substring(4);
- // 兼容语义维度名(如 dan_mind / dan_wisdom)
- if (DanReportUpload.DIM_MIND.equals(code) || DanReportUpload.DIM_WISDOM.equals(code)
- || DanReportUpload.DIM_COGNITION.equals(code) || DanReportUpload.DIM_LEARNING.equals(code)
- || DanReportUpload.DIM_BEHAVIOR.equals(code) || DanReportUpload.DIM_CAREER.equals(code)
- || DanReportUpload.DIM_CAMPUS.equals(code) || DanReportUpload.DIM_ADOLESCENT.equals(code)) {
- return code;
- }
- // report_type_registry.typeId 编码 → 测评维度
- switch (code) {
- case "a1": return DanReportUpload.DIM_COGNITION;
- case "a2": return DanReportUpload.DIM_MIND;
- case "b2": return DanReportUpload.DIM_BEHAVIOR;
- case "b3": return DanReportUpload.DIM_LEARNING;
- case "b4": return DanReportUpload.DIM_WISDOM;
- case "b5": return DanReportUpload.DIM_ADOLESCENT;
- case "b6": return DanReportUpload.DIM_CAREER;
- case "c1": return DanReportUpload.DIM_CAMPUS;
- default: break;
- }
- }
- return DanReportUpload.DIM_MIND;
- }
- private Long resolveDanFamilyId(Long memberId) {
- if (memberId == null) {
- return null;
- }
- FamilyMember member = familyMemberMapper.selectById(memberId);
- return member != null ? member.getFamilyId() : null;
- }
- /** file_id 存相对路径或文件名(对齐 DanReportUploadService.toShortFileId,避免 Data truncation) */
- private String toShortFileId(String fileUrl) {
- if (fileUrl == null || fileUrl.isEmpty()) {
- return null;
- }
- int uploadsIdx = fileUrl.indexOf("/uploads/");
- if (uploadsIdx >= 0) {
- return fileUrl.substring(uploadsIdx + 1);
- }
- int slash = fileUrl.lastIndexOf('/');
- if (slash >= 0 && slash < fileUrl.length() - 1) {
- return fileUrl.substring(slash + 1);
- }
- return fileUrl.length() > 500 ? fileUrl.substring(0, 500) : fileUrl;
- }
- private void harmReportPayloadValid(ParsedReportPayload.Payload payload) {
- if (payload.getSummary() == null && (payload.getIndicators() == null || payload.getIndicators().isEmpty())
- && (payload.getGutFlora() == null || payload.getGutFlora().isEmpty())) {
- throw new IllegalArgumentException("解析结果中未提取到有效指标数据");
- }
- }
- private String resolvePdfPath(HealthReportDraft draft) {
- if (draft.getFileUrl() == null) {
- return null;
- }
- try {
- String p = storageService.getFilePath(draft.getFileUrl());
- File f = new File(p);
- return f.exists() ? f.getAbsolutePath() : null;
- } catch (Exception e) {
- log.warn("解析文件路径失败 fileUrl={}", draft.getFileUrl(), e);
- return null;
- }
- }
- /**
- * 检查文件是否为 PDF(通过魔数 %PDF- 判断)。
- * 指纹引擎、LangGraph 解析、opencode 均要求 PDF 输入。
- */
- private boolean isPdfFile(String filePath) {
- try (FileInputStream fis = new FileInputStream(filePath)) {
- byte[] header = new byte[5];
- int read = fis.read(header);
- return read == 5 && new String(header, StandardCharsets.US_ASCII).startsWith("%PDF-");
- } catch (Exception e) {
- log.debug("PDF魔数检查失败 path={}: {}", filePath, e.getMessage());
- return false;
- }
- }
- /** 未知类型:交本地 opencode 服务。因 headless serve 仅单轮文本生成可靠且输出有限,
- * 采用分块单轮解析:每一块独立 session、单轮返回该 section 的 JSON,块间互不依赖,
- * 最后合并为完整 Payload,避免单轮输出截断导致整单失败。
- */
- private ParsedReportPayload.Payload parseWithOpencode(String pdfPath, String fileName) {
- String pdfText = extractPdfText(pdfPath);
- ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
- int produced = 0;
- JsonNode sr = opencodeSection(pdfText, fileName,
- "只提取 summary 和 diseaseRisks 两部分,其余字段不返回",
- "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"chronicDiseaseScore\":0,\"nutritionScore\":0,\"balanceScore\":0,\"diversityScore\":0,\"beneficialScore\":0,\"harmfulScore\":0,\"coreGenusScore\":0,\"gutAge\":\"\",\"gutType\":\"\",\"shannonIndex\":\"\",\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\",\"age\":0,\"birthday\":\"\"},\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}]}");
- if (sr != null) {
- if (sr.has("summary") && !sr.get("summary").isNull()) {
- payload.setSummary(objectMapper.convertValue(sr.get("summary"), ParsedReportPayload.Summary.class));
- produced++;
- }
- if (sr.has("diseaseRisks") && sr.get("diseaseRisks").isArray()) {
- payload.setDiseaseRisks(objectMapper.convertValue(sr.get("diseaseRisks"),
- new TypeReference<List<ParsedReportPayload.DiseaseRisk>>() {}));
- produced++;
- }
- }
- JsonNode ind = opencodeSection(pdfText, fileName,
- "只提取 indicators 指标数组,其余字段不返回",
- "{\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}]}");
- if (ind != null && ind.has("indicators") && ind.get("indicators").isArray()) {
- payload.setIndicators(objectMapper.convertValue(ind.get("indicators"),
- new TypeReference<List<ParsedReportPayload.Indicator>>() {}));
- produced++;
- }
- TypeReference<List<ParsedReportPayload.Flora>> floraType = new TypeReference<List<ParsedReportPayload.Flora>>() {};
- JsonNode floraMain = opencodeSection(pdfText, fileName,
- "只提取 gutFlora、probioticSpecies、pathogenGenus、pathogenDetection 四部分菌群数据,其余字段不返回",
- "{\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\"probioticSpecies\":[],\"pathogenGenus\":[],\"pathogenDetection\":[]}",
- true);
- if (floraMain != null) {
- putFlora(payload::setGutFlora, floraMain, "gutFlora", floraType);
- putFlora(payload::setProbioticSpecies, floraMain, "probioticSpecies", floraType);
- putFlora(payload::setPathogenGenus, floraMain, "pathogenGenus", floraType);
- putFlora(payload::setPathogenDetection, floraMain, "pathogenDetection", floraType);
- if (payload.getGutFlora() != null || payload.getProbioticSpecies() != null
- || payload.getPathogenGenus() != null || payload.getPathogenDetection() != null) {
- produced++;
- }
- }
- JsonNode taxHigh = opencodeSection(pdfText, fileName,
- "只提取 taxonomyClass、taxonomyOrder、taxonomyFamily 三个分类层级占比数据,其余字段不返回",
- "{\"taxonomyClass\":[],\"taxonomyOrder\":[],\"taxonomyFamily\":[]}",
- true);
- if (taxHigh != null) {
- putFlora(payload::setTaxonomyClass, taxHigh, "taxonomyClass", floraType);
- putFlora(payload::setTaxonomyOrder, taxHigh, "taxonomyOrder", floraType);
- putFlora(payload::setTaxonomyFamily, taxHigh, "taxonomyFamily", floraType);
- }
- JsonNode taxLow = opencodeSection(pdfText, fileName,
- "只提取 taxonomyGenus、taxonomySpecies 两个分类层级占比数据,其余字段不返回",
- "{\"taxonomyGenus\":[],\"taxonomySpecies\":[]}",
- true);
- if (taxLow != null) {
- putFlora(payload::setTaxonomyGenus, taxLow, "taxonomyGenus", floraType);
- putFlora(payload::setTaxonomySpecies, taxLow, "taxonomySpecies", floraType);
- }
- if (produced == 0
- && payload.getGutFlora() == null && payload.getTaxonomyClass() == null
- && payload.getTaxonomyGenus() == null && payload.getTaxonomySpecies() == null) {
- return null;
- }
- return payload;
- }
- private void putFlora(Consumer<List<ParsedReportPayload.Flora>> setter, JsonNode section, String field,
- TypeReference<List<ParsedReportPayload.Flora>> type) {
- try {
- if (section.has(field) && section.get(field).isArray()) {
- setter.accept(objectMapper.convertValue(section.get(field), type));
- }
- } catch (Exception e) {
- log.warn("菌群字段 {} 转换失败,跳过: {}", field, e.getMessage());
- }
- }
- private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema) {
- return opencodeSection(pdfText, fileName, instruction, schema, false);
- }
- /**
- * 单轮分块解析:新建 session、发送单个提示词、校验返回为合法 JSON。
- * 若第一轮输出被截断/非法,重试一次并指示精简输出(去掉空字段、用紧凑格式)。
- * compactFirst=true 时首轮即要求精简(用于数据量大的菌群块,避免首轮就超预算)。
- */
- private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema, boolean compactFirst) {
- String prompt = buildSectionPrompt(pdfText, fileName, instruction, schema, compactFirst);
- JsonNode node = sendOnceAndParse(prompt);
- if (node != null) {
- return node;
- }
- String retryPrompt = buildSectionPrompt(pdfText, fileName, instruction, schema, true);
- return sendOnceAndParse(retryPrompt);
- }
- private String buildSectionPrompt(String pdfText, String fileName, String instruction, String schema, boolean compact) {
- StringBuilder sb = new StringBuilder();
- sb.append("下面是健康检测报告(文件名:").append(fileName != null ? fileName : "未知")
- .append(")用 pdftotext 提取出的全文文本。").append(instruction)
- .append(",仅返回严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n")
- .append(schema).append('\n');
- if (compact) {
- sb.append("要求:1) 省略所有空字段(值为空或 null 的字段不要输出);2) 数值用紧凑格式;3) 每种数组最多返回 40 条(超出的按重要性取前 40 条);4) 不要省略合法数据。\n");
- }
- sb.append("报告全文文本如下:\n").append(pdfText).append('\n')
- .append("若无法提取某部分数据返回空数组或 null。");
- return sb.toString();
- }
- private JsonNode sendOnceAndParse(String prompt) {
- String sessionId = opencodeCreateSession();
- try {
- String reply = opencodeSendAndWait(sessionId, prompt);
- if (reply == null || reply.trim().isEmpty()) {
- return null;
- }
- String json = extractJson(reply);
- return objectMapper.readTree(json);
- } catch (Exception e) {
- log.warn("opencode 分块输出转载荷失败: {}", e.getMessage());
- return null;
- } finally {
- deleteSession(sessionId);
- }
- }
- private void deleteSession(String sessionId) {
- try {
- restTemplate.exchange(opencodeBase + "/session/" + sessionId,
- HttpMethod.DELETE, new HttpEntity<>(opencodeHeaders()), Void.class);
- } catch (Exception e) {
- log.debug("opencode 删除会话失败 sessionId={}: {}", sessionId, e.getMessage());
- }
- }
- /** 用 pdftotext 提取 PDF 文本,失败时返回可读提示(避免模型误读图片型 PDF) */
- private String extractPdfText(String pdfPath) {
- try {
- File file = new File(pdfPath);
- if (!file.exists() || !file.isFile()) {
- return "PDF 文件不存在或不是文件:" + pdfPath;
- }
- ProcessBuilder pb = new ProcessBuilder("pdftotext", "-layout", pdfPath, "-");
- pb.redirectErrorStream(true);
- Process process = pb.start();
- StringBuilder sb = new StringBuilder();
- try (BufferedReader reader = new BufferedReader(
- new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
- String line;
- while ((line = reader.readLine()) != null) {
- sb.append(line).append('\n');
- }
- }
- if (!process.waitFor(30, TimeUnit.SECONDS)) {
- process.destroyForcibly();
- log.warn("pdftotext 超时 pdfPath={}", pdfPath);
- return "PDF 文本提取超时。" + pdfPath;
- }
- String text = sb.toString();
- if (text.trim().isEmpty()) {
- return "该 PDF 可能为扫描图片,无可用文本。" + pdfPath;
- }
- // 控制提示词体积:仅保留前 60k 字符,超长截断
- return text.length() > 60000 ? text.substring(0, 60000) : text;
- } catch (Exception e) {
- log.warn("pdftotext 提取失败 pdfPath={}: {}", pdfPath, e.getMessage());
- return "PDF 文本提取失败:" + e.getMessage();
- }
- }
- /** 从回复文本中提取 JSON(容忍 code fence 或前导说明) */
- private String extractJson(String reply) {
- String s = reply.trim();
- int start = s.indexOf('{');
- int end = s.lastIndexOf('}');
- if (start >= 0 && end > start) {
- return s.substring(start, end + 1);
- }
- return s;
- }
- /** 采集完成后自动入库(复用 confirm 的入库逻辑) */
- private void insertReport(HealthReportDraft draft, ParsedReportPayload.Payload payload) {
- Long subjectId = null;
- if (draft.getFamilyId() != null && payload.getSummary() != null
- && payload.getSummary().getPersonName() != null) {
- subjectId = healthReportService.matchFamilyMemberByName(
- payload.getSummary().getPersonName(), draft.getFamilyId());
- }
- HealthReport report = buildReportFromPayload(draft, payload, subjectId);
- List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
- List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
- List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
- HealthReport created = healthReportService.createReport(report, indicators, gutFlora, diseaseRisks);
- try {
- healthReportService.savePayloadSnapshot(created.getId(), draft.getUserId(), "create", null,
- objectMapper.writeValueAsString(payload));
- } catch (Exception e) {
- log.warn("保存载荷快照失败 reportId={}", created.getId(), e);
- }
- if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
- try {
- List<Food> foods = buildFoodsFromPayload(payload);
- foodService.saveFoodsForReport(created.getId(), foods);
- } catch (Exception e) {
- log.warn("保存食材推荐失败 reportId={}", created.getId(), e);
- }
- }
- if (subjectId != null) {
- healthReportService.bindReportToMember(created.getId(), subjectId);
- try {
- dimensionScoreService.refreshFromGutReport(subjectId, created.getId());
- } catch (Exception e) {
- log.warn("刷新7维健康评分失败 memberId={}", subjectId, e);
- }
- try {
- foodRecommendService.recalculateForUser(subjectId);
- } catch (Exception e) {
- log.warn("刷新食材推荐指数失败 memberId={}", subjectId, e);
- }
- }
- try {
- reportBlockService.save(created.getReportType(), created.getId(),
- reportBlockAssembler.assembleWithTemplate(created.getReportType(), payload));
- } catch (Exception e) {
- log.warn("保存展示块失败 reportId={}", created.getId(), e);
- }
- healthReportDraftService.markCollectCompleted(draft.getId(), created.getId());
- // 新手任务联动:完成首次报告上传任务(老用户无任务记录时静默跳过)
- try { taskService.completeOnboardingTask(draft.getUserId(), "REPORT_UPLOAD"); } catch (Exception e) { log.warn("新手任务 REPORT_UPLOAD 完成失败: {}", e.getMessage()); }
- }
- private HealthReport buildReportFromPayload(HealthReportDraft draft, ParsedReportPayload.Payload payload, Long subjectId) {
- HealthReport report = new HealthReport();
- report.setUserId(draft.getUserId());
- report.setFamilyId(draft.getFamilyId());
- report.setSubjectId(subjectId);
- report.setReportType(draft.getReportType());
- report.setFileUrl(draft.getFileUrl());
- report.setFileHash(draft.getFileHash());
- ParsedReportPayload.Summary s = payload.getSummary();
- if (s != null) {
- report.setOverallScore(s.getOverallScore());
- report.setGutHealthScore(s.getGutHealthScore());
- report.setChronicDiseaseScore(s.getChronicDiseaseScore());
- report.setNutritionScore(s.getNutritionScore());
- report.setBalanceScore(s.getBalanceScore());
- report.setDiversityScore(s.getDiversityScore());
- report.setBeneficialScore(s.getBeneficialScore());
- report.setHarmfulScore(s.getHarmfulScore());
- report.setCoreGenusScore(s.getCoreGenusScore());
- report.setGutAge(s.getGutAge());
- report.setGutType(s.getGutType());
- report.setShannonIndex(s.getShannonIndex());
- report.setReportNumber(s.getReportNumber());
- report.setPersonName(s.getPersonName());
- report.setReportDate(parseDate(s.getReportDate()));
- }
- if (report.getReportDate() == null) {
- report.setReportDate(new Date());
- }
- report.setRemark("异步采集自动入库");
- return report;
- }
- private Date parseDate(String dateStr) {
- if (dateStr == null || dateStr.isEmpty()) {
- return null;
- }
- try {
- SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
- return sdf.parse(dateStr);
- } catch (Exception e) {
- try {
- SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy/MM/dd");
- return sdf2.parse(dateStr);
- } catch (Exception e2) {
- return null;
- }
- }
- }
- private List<HealthIndicator> buildIndicatorsFromPayload(ParsedReportPayload.Payload payload) {
- List<ParsedReportPayload.Indicator> list = payload.getIndicators();
- if (list == null || list.isEmpty()) return new ArrayList<>();
- List<HealthIndicator> indicators = new ArrayList<>();
- for (int i = 0; i < list.size(); i++) {
- ParsedReportPayload.Indicator pi = list.get(i);
- HealthIndicator ind = new HealthIndicator();
- ind.setCategory(pi.getCategory());
- ind.setIndicatorName(pi.getIndicatorName());
- ind.setIndicatorValue(pi.getIndicatorValue());
- ind.setUnit(pi.getUnit());
- ind.setRefRange(pi.getRefRange());
- ind.setStatus(pi.getStatus());
- ind.setSymptoms(pi.getSymptoms());
- ind.setSortOrder(i);
- ind.setCollectionDate(parseDate(pi.getCollectionDate()));
- indicators.add(ind);
- }
- return indicators;
- }
- private List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
- List<HealthGutFlora> result = new ArrayList<>();
- addFloraList(result, payload.getGutFlora());
- addFloraList(result, payload.getProbioticSpecies());
- addFloraList(result, payload.getTaxonomyClass());
- addFloraList(result, payload.getTaxonomyOrder());
- addFloraList(result, payload.getTaxonomyFamily());
- addFloraList(result, payload.getTaxonomyGenus());
- addFloraList(result, payload.getTaxonomySpecies());
- addFloraList(result, payload.getPathogenGenus());
- addFloraList(result, payload.getPathogenDetection());
- return result;
- }
- private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
- if (source != null) {
- for (ParsedReportPayload.Flora pf : source) {
- String bacteriaName = pf.getBacteriaName();
- if (bacteriaName == null || bacteriaName.trim().isEmpty()) {
- log.warn("跳过无菌种名称的菌群数据(category={}),避免 NOT NULL 约束冲突", pf.getCategory());
- continue;
- }
- HealthGutFlora gf = new HealthGutFlora();
- gf.setBacteriaName(bacteriaName);
- gf.setBacteriaValue(pf.getBacteriaValue());
- gf.setNormalRange(pf.getNormalRange());
- gf.setPopulationLevel(pf.getPopulationLevel());
- gf.setDetectionRate(pf.getDetectionRate());
- gf.setDescription(pf.getDescription());
- gf.setCategory(normalizeFloraCategory(pf.getCategory()));
- gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
- target.add(gf);
- }
- }
- }
- private String normalizeFloraCategory(String category) {
- if (category == null) return null;
- if (category.endsWith("构成")) {
- return HealthGutFlora.CATEGORY_TAXONOMY;
- }
- return category;
- }
- private String normalizeFloraLevel(String category, String level) {
- if (level == null) return null;
- String normalized = level;
- if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
- else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
- else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
- else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
- else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
- else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
- return normalized;
- }
- private List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
- List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
- if (list == null || list.isEmpty()) return new ArrayList<>();
- List<HealthDiseaseRisk> risks = new ArrayList<>();
- for (ParsedReportPayload.DiseaseRisk pr : list) {
- HealthDiseaseRisk dr = new HealthDiseaseRisk();
- dr.setDiseaseName(pr.getDiseaseName());
- dr.setRiskValue(pr.getRiskValue());
- dr.setRiskLevel(pr.getRiskLevel());
- risks.add(dr);
- }
- return risks;
- }
- private List<Food> buildFoodsFromPayload(ParsedReportPayload.Payload payload) {
- List<ParsedReportPayload.FoodItem> list = payload.getFoods();
- if (list == null || list.isEmpty()) return new ArrayList<>();
- List<Food> foods = new ArrayList<>();
- for (ParsedReportPayload.FoodItem pf : list) {
- Food food = new Food();
- food.setName(pf.getName());
- food.setCategory(pf.getCategory());
- food.setScore(pf.getScore());
- food.setEnergyKj(pf.getEnergyKj());
- food.setProtein(pf.getProtein());
- food.setFat(pf.getFat());
- food.setCarbs(pf.getCarbs());
- food.setStarch(pf.getStarch());
- food.setFiber(pf.getFiber());
- food.setCholesterol(pf.getCholesterol());
- food.setStatus("recommended");
- foods.add(food);
- }
- return foods;
- }
- // ===== opencode 本地服务 HTTP 客户端(协议对齐 运营文案/opencode_client.py) =====
- private String opencodeCreateSession() {
- try {
- String url = opencodeBase + "/session?directory="
- + java.net.URLEncoder.encode(opencodeWorkdir, StandardCharsets.UTF_8.name());
- Map<String, Object> body = new LinkedHashMap<>();
- body.put("title", "report-collect-" + System.currentTimeMillis());
- body.put("agent", "report-collect");
- // 显式固定模型(Provider/Model id 在 body 中传递,服务端才尊重;query 参数会被忽略)
- Map<String, Object> model = new LinkedHashMap<>();
- model.put("id", opencodeModelId);
- model.put("providerID", opencodeModelProvider);
- body.put("model", model);
- ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST,
- new HttpEntity<>(body, opencodeHeaders()), Map.class);
- Map<?, ?> data = resp.getBody();
- if (data == null || data.get("id") == null) {
- throw new IllegalStateException("opencode 创建会话未返回 session_id");
- }
- return data.get("id").toString();
- } catch (Exception e) {
- throw new IllegalStateException("opencode 创建会话失败: " + e.getMessage(), e);
- }
- }
- /** 发送提示词并轮询取回 assistant 最终文本回复 */
- private String opencodeSendAndWait(String sessionId, String prompt) {
- HttpHeaders headers = opencodeHeaders();
- List<Map<String, Object>> parts = new ArrayList<>();
- Map<String, Object> part = new LinkedHashMap<>();
- part.put("type", "text");
- part.put("text", prompt);
- parts.add(part);
- Map<String, Object> body = new LinkedHashMap<>();
- body.put("parts", parts);
- body.put("agent", "report-collect");
- // 只读约束:仅允许分析/读取,禁用执行/写入类工具(与本地运营脚本对齐)
- Map<String, Object> tools = new LinkedHashMap<>();
- for (String t : new String[]{"bash", "shell", "write", "edit", "apply_patch",
- "task", "dispatch", "webfetch_post", "chrome_launch"}) {
- tools.put(t, Boolean.FALSE);
- }
- body.put("tools", tools);
- restTemplate.exchange(opencodeBase + "/session/" + sessionId + "/prompt_async",
- HttpMethod.POST, new HttpEntity<>(body, headers), Void.class);
- long deadline = System.currentTimeMillis() + 180000;
- long pollInterval = 5000;
- int before = countMessages(sessionId);
- while (System.currentTimeMillis() < deadline) {
- try {
- Thread.sleep(pollInterval);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- return null;
- }
- List<Map<String, Object>> msgs = listMessages(sessionId);
- if (msgs == null || msgs.isEmpty()) {
- continue;
- }
- Map<String, Object> last = msgs.get(msgs.size() - 1);
- String role = roleOf(last);
- if ("assistant".equals(role)) {
- String text = lastAssistantText(last);
- if (text != null && !text.trim().isEmpty()) {
- return text;
- }
- }
- if (msgs.size() > before) {
- for (int i = msgs.size() - 1; i >= 0; i--) {
- String r = roleOf(msgs.get(i));
- if ("assistant".equals(r)) {
- String t = lastAssistantText(msgs.get(i));
- if (t != null && !t.trim().isEmpty()) {
- return t;
- }
- }
- }
- }
- }
- throw new IllegalStateException("等待 opencode 回复超时(180s)");
- }
- @SuppressWarnings("unchecked")
- private String roleOf(Map<String, Object> msg) {
- Map<String, Object> info = (Map<String, Object>) msg.get("info");
- return info != null ? (String) info.get("role") : null;
- }
- @SuppressWarnings("unchecked")
- private String lastAssistantText(Map<String, Object> msg) {
- Object partsObj = msg.get("parts");
- if (!(partsObj instanceof List)) return null;
- List<Object> parts = (List<Object>) partsObj;
- String text = null;
- for (Object pObj : parts) {
- if (pObj instanceof Map) {
- Map<String, Object> p = (Map<String, Object>) pObj;
- if ("text".equals(p.get("type")) && p.get("text") != null) {
- text = p.get("text").toString();
- }
- }
- }
- return text;
- }
- private int countMessages(String sessionId) {
- List<Map<String, Object>> msgs = listMessages(sessionId);
- return msgs == null ? 0 : msgs.size();
- }
- @SuppressWarnings("unchecked")
- private List<Map<String, Object>> listMessages(String sessionId) {
- try {
- ResponseEntity<List> resp = restTemplate.exchange(
- opencodeBase + "/session/" + sessionId + "/message",
- HttpMethod.GET, new HttpEntity<>(opencodeHeaders()), List.class);
- return resp.getBody();
- } catch (Exception e) {
- log.warn("opencode 查询消息失败 session={}", sessionId, e);
- return null;
- }
- }
- private HttpHeaders opencodeHeaders() {
- String token = Base64.getEncoder().encodeToString(
- (opencodeUser + ":" + opencodePassword).getBytes(StandardCharsets.UTF_8));
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("Authorization", "Basic " + token);
- return headers;
- }
- }
|