|
|
@@ -0,0 +1,347 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.dto.ParsedReportPayload;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * LangGraph 报告解析服务(采集流程)。
|
|
|
+ *
|
|
|
+ * 采集方案:先指纹识别,指纹成功走"脚本"——脚本已迁移到 LangGraph,即 /report/parse
|
|
|
+ * (pdf_parser 算法 + LLM 兜底,输出归一化为 Java 可消费的 snake_case 结构);
|
|
|
+ * 无指纹则先用 /report/detect-type 识别:识别到具体类型(brain_status /
|
|
|
+ * cognitive_aptitude / scanned_image)走 /parse-typed 用其结果;识别不了(generic)
|
|
|
+ * 仍尝试 /report/parse(算法可解析标准肠道菌群 triplet/inline/beijing 格式,实测
|
|
|
+ * detect-type 返回 generic 时 parse 依然成功);仍无有效数据返回 null,由调用方回退 opencode。
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class LangGraphReportParseService {
|
|
|
+
|
|
|
+ /** LangGraph 识别出的类型:无法识别 */
|
|
|
+ public static final String DETECTED_GENERIC = "generic";
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private RestTemplate restTemplate;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ObjectMapper objectMapper;
|
|
|
+
|
|
|
+ @Value("${langgraph.base-url:http://localhost:9000}")
|
|
|
+ private String langgraphBaseUrl;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 指纹成功路径:调 LangGraph 脚本(/report/parse,算法 + LLM 兜底)。
|
|
|
+ * 转换后无有效指标数据时返回 null。
|
|
|
+ */
|
|
|
+ public ParsedReportPayload.Payload parseByScript(String pdfPath) {
|
|
|
+ Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath);
|
|
|
+ if (data == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ ParsedReportPayload.Payload payload = buildPayloadFromLgData(data);
|
|
|
+ return isValid(payload) ? payload : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 无指纹路径:先用 LangGraph 识别报告类型。
|
|
|
+ * 识别到具体类型(非 generic)→ /parse-typed 专用解析;
|
|
|
+ * generic 或 typed 结果无效 → 仍试 /report/parse(算法可解析标准菌群格式)。
|
|
|
+ * 均无有效数据时返回 null,调用方回退 opencode。
|
|
|
+ */
|
|
|
+ public ParsedReportPayload.Payload parseWithDetect(String pdfPath) {
|
|
|
+ String detected = detectType(pdfPath);
|
|
|
+ if (detected != null && !DETECTED_GENERIC.equals(detected)) {
|
|
|
+ ParsedReportPayload.Payload typed = parseByType(pdfPath, detected);
|
|
|
+ if (typed != null) {
|
|
|
+ log.info("LangGraph 识别到类型 {} 并解析成功", detected);
|
|
|
+ return typed;
|
|
|
+ }
|
|
|
+ log.warn("LangGraph 识别类型 {} 但解析结果无效,回退算法脚本", detected);
|
|
|
+ } else {
|
|
|
+ log.info("LangGraph 无法识别报告类型(generic),尝试算法脚本解析");
|
|
|
+ }
|
|
|
+ return parseByScript(pdfPath);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 调 /report/detect-type 识别报告类型,失败返回 null */
|
|
|
+ public String detectType(String pdfPath) {
|
|
|
+ Map<String, Object> data = postParse("/api/v1/report/detect-type", pdfPath);
|
|
|
+ if (data == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ Object detected = data.get("detectedType");
|
|
|
+ return detected != null ? detected.toString() : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 调 /report/parse-typed 按指定类型解析(brain_status/cognitive_aptitude/scanned_image) */
|
|
|
+ private ParsedReportPayload.Payload parseByType(String pdfPath, String reportType) {
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("file_path", pdfPath);
|
|
|
+ body.put("report_type", reportType);
|
|
|
+ Map<String, Object> data = postParse("/api/v1/report/parse-typed", body);
|
|
|
+ if (data == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ ParsedReportPayload.Payload payload = buildPayloadFromTypedData(data);
|
|
|
+ return isValid(payload) ? payload : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== HTTP 调用 =====
|
|
|
+
|
|
|
+ /** 调 LangGraph 端点(仅为 file_path 场景),返回 data 字典;网络错误/非200/空 data 返回 null */
|
|
|
+ private Map<String, Object> postParse(String path, String pdfPath) {
|
|
|
+ Map<String, Object> body = new HashMap<>();
|
|
|
+ body.put("file_path", pdfPath);
|
|
|
+ return postParse(path, body);
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, Object> postParse(String path, Map<String, Object> body) {
|
|
|
+ try {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
|
|
|
+ ResponseEntity<Map> resp = restTemplate.postForEntity(langgraphBaseUrl + path, entity, Map.class);
|
|
|
+ Map<String, Object> respBody = resp.getBody();
|
|
|
+ if (respBody == null || !Integer.valueOf(200).equals(respBody.get("code"))) {
|
|
|
+ log.warn("LangGraph 端点 {} 返回异常: code={}, message={}",
|
|
|
+ path, respBody != null ? respBody.get("code") : null,
|
|
|
+ respBody != null ? respBody.get("message") : null);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ Object data = respBody.get("data");
|
|
|
+ if (!(data instanceof Map) || ((Map<String, Object>) data).isEmpty()) {
|
|
|
+ log.warn("LangGraph 端点 {} 返回空 data", path);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return (Map<String, Object>) data;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("LangGraph 端点 {} 调用失败: {}", path, e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 转换:LangGraph 输出 → ParsedReportPayload.Payload =====
|
|
|
+
|
|
|
+ /** 判定载荷是否含有效指标数据(与 ReportCollectService.harmReportPayloadValid 一致) */
|
|
|
+ private boolean isValid(ParsedReportPayload.Payload payload) {
|
|
|
+ return payload != null
|
|
|
+ && (payload.getSummary() != null
|
|
|
+ || (payload.getIndicators() != null && !payload.getIndicators().isEmpty())
|
|
|
+ || (payload.getGutFlora() != null && !payload.getGutFlora().isEmpty()));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 转换 /report/parse 输出(overview 等 snake_case + 菌群列表),逻辑与 HealthReportController 一致 */
|
|
|
+ private ParsedReportPayload.Payload buildPayloadFromLgData(Map<String, Object> lgData) {
|
|
|
+ ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
|
|
|
+
|
|
|
+ Map<String, Object> overview = (Map<String, Object>) lgData.get("overview");
|
|
|
+ if (overview != null) {
|
|
|
+ ParsedReportPayload.Summary summary = new ParsedReportPayload.Summary();
|
|
|
+ summary.setPersonName(objectToString(overview.get("person_name")));
|
|
|
+ summary.setAge(toInteger(overview.get("age")));
|
|
|
+ summary.setGender(objectToString(overview.get("gender")));
|
|
|
+ summary.setOverallScore(toInteger(overview.get("overallScore")));
|
|
|
+ summary.setGutHealthScore(toInteger(overview.get("gutHealthScore")));
|
|
|
+ summary.setChronicDiseaseScore(toInteger(overview.get("chronicDiseaseScore")));
|
|
|
+ summary.setNutritionScore(toInteger(overview.get("nutritionScore")));
|
|
|
+ summary.setBalanceScore(toInteger(overview.get("balanceScore")));
|
|
|
+ summary.setDiversityScore(toInteger(overview.get("diversityScore")));
|
|
|
+ summary.setBeneficialScore(toInteger(overview.get("beneficialScore")));
|
|
|
+ summary.setHarmfulScore(toInteger(overview.get("harmfulScore")));
|
|
|
+ summary.setCoreGenusScore(toInteger(overview.get("coreGenusScore")));
|
|
|
+ summary.setGutAge(objectToString(overview.get("gutAge")));
|
|
|
+ summary.setGutType(objectToString(overview.get("gutType")));
|
|
|
+ summary.setShannonIndex(objectToString(overview.get("shannonIndex")));
|
|
|
+ summary.setReportNumber(objectToString(overview.get("report_number")));
|
|
|
+ summary.setReportDate(objectToString(overview.get("report_date")));
|
|
|
+ summary.setBirthday(objectToString(overview.get("birthday")));
|
|
|
+ payload.setSummary(summary);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> diseaseRisks = (List<Map<String, Object>>) lgData.get("disease_risks");
|
|
|
+ if (diseaseRisks != null) {
|
|
|
+ List<ParsedReportPayload.DiseaseRisk> risks = new ArrayList<>();
|
|
|
+ for (Map<String, Object> dr : diseaseRisks) {
|
|
|
+ ParsedReportPayload.DiseaseRisk risk = new ParsedReportPayload.DiseaseRisk();
|
|
|
+ risk.setDiseaseName(objectToString(dr.get("name")));
|
|
|
+ risk.setRiskValue(objectToString(dr.get("value")));
|
|
|
+ risk.setRiskLevel(objectToString(dr.get("status")));
|
|
|
+ risks.add(risk);
|
|
|
+ }
|
|
|
+ payload.setDiseaseRisks(risks);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> indicators = (List<Map<String, Object>>) lgData.get("indicators");
|
|
|
+ if (indicators != null) {
|
|
|
+ List<ParsedReportPayload.Indicator> inds = new ArrayList<>();
|
|
|
+ for (Map<String, Object> ind : indicators) {
|
|
|
+ ParsedReportPayload.Indicator indicator = new ParsedReportPayload.Indicator();
|
|
|
+ indicator.setCategory(objectToString(ind.get("category")));
|
|
|
+ indicator.setIndicatorName(objectToString(ind.get("indicatorName")));
|
|
|
+ indicator.setIndicatorValue(objectToString(ind.get("indicatorValue")));
|
|
|
+ indicator.setUnit(objectToString(ind.get("unit")));
|
|
|
+ indicator.setRefRange(objectToString(ind.get("refRange")));
|
|
|
+ indicator.setStatus(objectToString(ind.get("status")));
|
|
|
+ indicator.setSymptoms(objectToString(ind.get("symptoms")));
|
|
|
+ inds.add(indicator);
|
|
|
+ }
|
|
|
+ payload.setIndicators(inds);
|
|
|
+ }
|
|
|
+
|
|
|
+ payload.setGutFlora(convertLgFloraList(lgData.get("gut_flora")));
|
|
|
+ payload.setProbioticSpecies(convertLgFloraList(lgData.get("probiotic_species")));
|
|
|
+ payload.setTaxonomyClass(convertLgFloraList(lgData.get("taxonomy_class")));
|
|
|
+ payload.setTaxonomyOrder(convertLgFloraList(lgData.get("taxonomy_order")));
|
|
|
+ payload.setTaxonomyFamily(convertLgFloraList(lgData.get("taxonomy_family")));
|
|
|
+ payload.setTaxonomyGenus(convertLgFloraList(lgData.get("taxonomy_genus")));
|
|
|
+ payload.setTaxonomySpecies(convertLgFloraList(lgData.get("taxonomy_species")));
|
|
|
+ payload.setPathogenGenus(convertLgFloraList(lgData.get("pathogen_genus")));
|
|
|
+ payload.setPathogenDetection(convertLgFloraList(lgData.get("pathogen_detection")));
|
|
|
+ payload.setFoods(convertLgFoodList(lgData.get("foods")));
|
|
|
+
|
|
|
+ return payload;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 转换 /parse-typed 输出(camelCase:summary.personName / indicators[].name/value/...)。
|
|
|
+ * 脑状态/智力潜能等认知类报告使用该结构,可提取 summary + indicators 到统一载荷。
|
|
|
+ */
|
|
|
+ private ParsedReportPayload.Payload buildPayloadFromTypedData(Map<String, Object> data) {
|
|
|
+ ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
|
|
|
+
|
|
|
+ Map<String, Object> summary = (Map<String, Object>) data.get("summary");
|
|
|
+ if (summary != null) {
|
|
|
+ ParsedReportPayload.Summary s = new ParsedReportPayload.Summary();
|
|
|
+ s.setPersonName(objectToString(summary.get("personName")));
|
|
|
+ s.setAge(toInteger(summary.get("age")));
|
|
|
+ s.setGender(objectToString(summary.get("gender")));
|
|
|
+ s.setReportDate(objectToString(summary.get("reportDate")));
|
|
|
+ s.setReportNumber(objectToString(summary.get("reportNumber")));
|
|
|
+ s.setOverallScore(toInteger(summary.get("overallScore")));
|
|
|
+ payload.setSummary(s);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> indicators = (List<Map<String, Object>>) data.get("indicators");
|
|
|
+ if (indicators != null) {
|
|
|
+ List<ParsedReportPayload.Indicator> inds = new ArrayList<>();
|
|
|
+ for (Map<String, Object> ind : indicators) {
|
|
|
+ ParsedReportPayload.Indicator indicator = new ParsedReportPayload.Indicator();
|
|
|
+ indicator.setCategory(objectToString(ind.get("category")));
|
|
|
+ indicator.setIndicatorName(objectToString(ind.get("name")));
|
|
|
+ indicator.setIndicatorValue(objectToString(ind.get("value")));
|
|
|
+ indicator.setStatus(objectToString(ind.get("status")));
|
|
|
+ inds.add(indicator);
|
|
|
+ }
|
|
|
+ payload.setIndicators(inds);
|
|
|
+ }
|
|
|
+
|
|
|
+ return payload;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 将 LangGraph 输出的菌群列表(snake_case 元素)转换为 Flora 列表,键缺失时安全返回空列表 */
|
|
|
+ private List<ParsedReportPayload.Flora> convertLgFloraList(Object raw) {
|
|
|
+ List<ParsedReportPayload.Flora> floraList = new ArrayList<>();
|
|
|
+ if (!(raw instanceof List)) {
|
|
|
+ return floraList;
|
|
|
+ }
|
|
|
+ for (Object item : (List<?>) raw) {
|
|
|
+ if (!(item instanceof Map)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Map<String, Object> m = (Map<String, Object>) item;
|
|
|
+ ParsedReportPayload.Flora f = new ParsedReportPayload.Flora();
|
|
|
+ f.setBacteriaName(objectToString(m.get("name")));
|
|
|
+ f.setBacteriaValue(objectToString(m.get("value")));
|
|
|
+ f.setNormalRange(objectToString(m.get("normal_range")));
|
|
|
+ f.setPopulationLevel(objectToString(m.get("population_level")));
|
|
|
+ f.setDetectionRate(objectToString(m.get("detection_rate")));
|
|
|
+ f.setDescription(objectToString(m.get("description")));
|
|
|
+ f.setCategory(objectToString(m.get("category")));
|
|
|
+ f.setLevel(objectToString(m.get("level")));
|
|
|
+ if (f.getBacteriaName() == null && f.getDescription() == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ floraList.add(f);
|
|
|
+ }
|
|
|
+ return floraList;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 将 LangGraph 输出的饮食推荐列表(snake_case 元素)转换为 FoodItem 列表 */
|
|
|
+ private List<ParsedReportPayload.FoodItem> convertLgFoodList(Object raw) {
|
|
|
+ List<ParsedReportPayload.FoodItem> foodList = new ArrayList<>();
|
|
|
+ if (!(raw instanceof List)) {
|
|
|
+ return foodList;
|
|
|
+ }
|
|
|
+ for (Object item : (List<?>) raw) {
|
|
|
+ if (!(item instanceof Map)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Map<String, Object> m = (Map<String, Object>) item;
|
|
|
+ ParsedReportPayload.FoodItem fi = new ParsedReportPayload.FoodItem();
|
|
|
+ fi.setName(objectToString(m.get("name")));
|
|
|
+ fi.setCategory(objectToString(m.get("category")));
|
|
|
+ fi.setScore(toInteger(m.get("score")));
|
|
|
+ fi.setEnergyKj(toDecimal(m.get("energy_kj")));
|
|
|
+ fi.setProtein(toDecimal(m.get("protein")));
|
|
|
+ fi.setFat(toDecimal(m.get("fat")));
|
|
|
+ fi.setCarbs(toDecimal(m.get("carbs")));
|
|
|
+ fi.setStarch(toDecimal(m.get("starch")));
|
|
|
+ fi.setFiber(toDecimal(m.get("fiber")));
|
|
|
+ fi.setCholesterol(toDecimal(m.get("cholesterol")));
|
|
|
+ if (fi.getName() == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ foodList.add(fi);
|
|
|
+ }
|
|
|
+ return foodList;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static BigDecimal toDecimal(Object value) {
|
|
|
+ if (value == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (value instanceof BigDecimal) {
|
|
|
+ return (BigDecimal) value;
|
|
|
+ }
|
|
|
+ if (value instanceof Number) {
|
|
|
+ return BigDecimal.valueOf(((Number) value).doubleValue());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return new BigDecimal(value.toString());
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Integer toInteger(Object value) {
|
|
|
+ if (value == null) return null;
|
|
|
+ if (value instanceof Integer) return (Integer) value;
|
|
|
+ if (value instanceof Number) return ((Number) value).intValue();
|
|
|
+ try {
|
|
|
+ return Integer.valueOf(value.toString());
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String objectToString(Object value) {
|
|
|
+ if (value == null) return null;
|
|
|
+ return value.toString();
|
|
|
+ }
|
|
|
+}
|