diff --git a/.env.example b/.env.example index 8655a6e..bae9572 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,11 @@ INGEST_API_KEYS=dev-key-1 AMAP_WEB_KEY= AMAP_JS_KEY= AMAP_SECURITY_JSCODE= + +# 阿里云 OCR (RecognizePassport 等), optional +ALIYUN_OCR_ACCESS_KEY_ID= +ALIYUN_OCR_ACCESS_KEY_SECRET= +ALIYUN_OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com GAODE_CRAWLER_PATH= TRAVEL_KG_DATA_ROOT=./data TRAVEL_AGENCY_SOURCE_ROOT=./data/source/travel_agency diff --git a/.gitignore b/.gitignore index 882ea56..987bb7b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ admin-web/tsconfig.tsbuildinfo # Runtime and generated local files logs/ tmp/ +.codex-tmp/ *.log *.pid app/static/admin/ diff --git a/Dockerfile b/Dockerfile index e5ec480..c7b395a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1.7 - FROM node:22-bookworm-slim AS admin-web-build WORKDIR /build/admin-web diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts index fd67f64..84bf6eb 100644 --- a/admin-web/src/api.ts +++ b/admin-web/src/api.ts @@ -175,8 +175,26 @@ export const plazaOverview = () => api.get("/plaza/overview"); export const plazaUsage = () => api.get("/plaza/usage"); export const plazaAlerts = () => api.get("/plaza/alerts"); export const plazaAmapConfig = () => api.get("/plaza/amap-config"); +export const plazaMapPois = () => api.get("/plaza/map-pois"); +export const plazaBusRoutes = () => api.get("/plaza/bus-routes"); +export const plazaMapPoiDetail = (placeId: string) => + api.get(`/plaza/map-pois/${encodeURIComponent(placeId)}`); export const plazaUserQuery = (data: unknown) => api.post("/plaza/user-query", data); export const travelAssistantQuery = (data: unknown) => api.post("/travel/assistant-query", data); + +// ── OCR (旅行社名单录入) ── +export const ocrPassport = (file: File) => { + const form = new FormData(); + form.append("file", file); + return api.post("/ocr/passport", form, { + headers: { "Content-Type": "multipart/form-data" }, + }); +}; +export const ocrPassportByUrl = (url: string) => { + const form = new FormData(); + form.append("url", url); + return api.post("/ocr/passport", form); +}; export const graphOverview = () => api.get("/graph/overview"); export const graphQuery = (cypher: string, limit = 200) => api.post("/graph/query", { cypher, limit }); diff --git a/admin-web/src/panels/plaza/GraphCanvas.tsx b/admin-web/src/panels/plaza/GraphCanvas.tsx index 2466aa9..c3b3c66 100644 --- a/admin-web/src/panels/plaza/GraphCanvas.tsx +++ b/admin-web/src/panels/plaza/GraphCanvas.tsx @@ -9,11 +9,11 @@ import * as echarts from "echarts"; import api, { displayGraphName, getProjectContext } from "../../api"; const CATEGORY_COLORS: Record = { - Place: "#00D4AA", Area: "#5B8FF9", Station: "#F6BD16", + Place: "#00D4AA", Area: "#5B8FF9", GeoCell: "#5B8FF9", 片区: "#5B8FF9", Station: "#F6BD16", BusStop: "#F6BD16", MetroStation: "#1677FF", TransitStop: "#F6BD16", BusLine: "#9270CA", - 地点: "#00D4AA", 景点: "#00D4AA", 美食: "#FF9D4D", 酒店: "#6DC8EC", - 商场: "#F6BD16", 公交站: "#F6BD16", 地铁站: "#1677FF", 交通站点: "#F6BD16", + 地点: "#00D4AA", 景点: "#1677FF", 景区: "#1677FF", 美食: "#FF5A1F", 酒店: "#722ED1", + 交通设施: "#13A8A8", 商场: "#F6BD16", 公交站: "#F6BD16", 地铁站: "#1677FF", 交通站点: "#F6BD16", RouteTemplate: "#E8684A", ExperienceTag: "#6DC8EC", Source: "#9270CA", Evidence: "#FF99C3", TourProduct: "#4C6FFF", ProductDay: "#6DC8EC", RouteStop: "#F6BD16", @@ -207,6 +207,15 @@ const FULL_GRAPH_LIMIT = 2500; const FULL_GRAPH_BATCH_SIZE = 1000; const FULL_GRAPH_BASE_CYPHER = "MATCH (n)-[r]->(m) RETURN n,r,m"; const GENERIC_DEFAULT_CYPHER = `${FULL_GRAPH_BASE_CYPHER} LIMIT ${FULL_GRAPH_LIMIT}`; +const LIBO_DEFAULT_CYPHER = [ + "MATCH (n:Hotel)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 100", + "UNION", + "MATCH (n:FoodPlace)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 100", + "UNION", + "MATCH (n:TransitFacility)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 60", + "UNION", + "MATCH (n:ScenicSpot)-[parentRel:PART_OF_SCENIC_AREA]->(parent) MATCH (n)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,parentRel,parent LIMIT 50", +].join(" "); const TRAVEL_CORE_CYPHER = [ "MATCH (p:TourProduct)-[lineStop:PRODUCT_HAS_ORDERED_STOP]->(s:RouteStop)", "OPTIONAL MATCH (s)-[next:ROUTE_STOP_NEXT]->(s2:RouteStop)", @@ -342,8 +351,19 @@ function isAnyTravelGraphName(value?: any): boolean { return name.includes("travel") || name.includes("旅行社") || name.includes("旅游"); } +function isYunyouLiboGraphName(value?: any): boolean { + const name = String(value || "").toLowerCase(); + return name.includes("yunyou_libo") || name.includes("云游荔波"); +} + function defaultCypherForGraph(value?: any): string { - return isTravelItemGraphName(value) ? TRAVEL_ITEM_CORE_CYPHER : (isAnyTravelGraphName(value) ? TRAVEL_DEFAULT_CYPHER : GENERIC_DEFAULT_CYPHER); + return isYunyouLiboGraphName(value) + ? LIBO_DEFAULT_CYPHER + : isTravelItemGraphName(value) + ? TRAVEL_ITEM_CORE_CYPHER + : isAnyTravelGraphName(value) + ? TRAVEL_DEFAULT_CYPHER + : GENERIC_DEFAULT_CYPHER; } function modeLabel(mode?: string): string { @@ -1355,7 +1375,25 @@ export default function GraphCanvas() { const currentGraphName = overview.graph_name || getProjectContext().graphName; const isTravelGraph = isAnyTravelGraphName(currentGraphName); const isTravelItemGraph = isTravelItemGraphName(currentGraphName); - const queryTemplates = isTravelItemGraph ? [ + const isYunyouLiboGraph = isYunyouLiboGraphName(currentGraphName); + const queryTemplates = isYunyouLiboGraph ? [ + { label: "四类业务全景", cypher: LIBO_DEFAULT_CYPHER }, + { label: "全部关系抽样", cypher: GENERIC_DEFAULT_CYPHER }, + { label: "酒店", cypher: "MATCH (n:Hotel) RETURN n LIMIT 150" }, + { label: "美食", cypher: "MATCH (n:FoodPlace) RETURN n LIMIT 150" }, + { label: "交通设施", cypher: "MATCH (n:TransitFacility) RETURN n LIMIT 150" }, + { label: "景点", cypher: "MATCH (n:ScenicSpot) RETURN n LIMIT 120" }, + { label: "景区", cypher: "MATCH (n:ScenicArea) RETURN n LIMIT 20" }, + { label: "公交线路停靠", cypher: "MATCH (line:BusRoute)-[r:STOPS_AT]->(stop:BusStop) RETURN line,r,stop LIMIT 350" }, + { label: "公交相邻站", cypher: "MATCH (a:BusStop)-[r:NEXT_STOP]->(b:BusStop) RETURN a,r,b LIMIT 320" }, + { label: "景点归属", cypher: "MATCH (spot:ScenicSpot)-[r:PART_OF_SCENIC_AREA]->(parent) RETURN spot,r,parent LIMIT 120" }, + { label: "POI 所在荔波县", cypher: "MATCH (poi:Place)-[r:LOCATED_IN]->(area:Area) WHERE area.adcode = '522722' RETURN poi,r,area LIMIT 300" }, + { + label: "POI 所属片区", + cypher: "MATCH (poi:Place)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN poi,r,cell,areaRel,area LIMIT 50", + }, + { label: "行政区层级", cypher: "MATCH (child:Area)-[r:PART_OF]->(parent:Area) RETURN child,r,parent LIMIT 20" }, + ] : isTravelItemGraph ? [ { label: "核心业务图", cypher: TRAVEL_ITEM_CORE_CYPHER }, { label: "固定线路骨架", cypher: TRAVEL_ITEM_ROUTE_SKELETON_CYPHER }, { label: "景区附近酒店餐饮", cypher: TRAVEL_ITEM_NEARBY_RESOURCE_CYPHER }, @@ -1408,6 +1446,8 @@ export default function GraphCanvas() { ? "输入自然语言或 Cypher,例如:黄果树天星桥有哪些必付小交通和可选二消?" : isTravelGraph ? "输入自然语言或 Cypher,例如:一家5人6天想去黄果树小七孔西江梵净山,推荐什么线路?" + : isYunyouLiboGraph + ? "输入自然语言或 Cypher,例如:查询荔波县的酒店、美食、交通设施或景点" : "输入自然语言或 Cypher,例如:花溪公园有哪些历史事件?"; const selectedTrafficInfo = selectedNode ? collectTrafficInfo(selectedNode, dataRef.current) : null; @@ -1488,7 +1528,11 @@ export default function GraphCanvas() { {resultInfo && {resultInfo}} - {isTravelGraph ? "默认展示固定线路主干;费用/规则/审计关系按需查询" : "支持自然语言和 Cypher"} · 单击看详情 · 双击展开关联 · 滚轮缩放 · 拖拽平移 + {isTravelGraph + ? "默认展示固定线路主干;费用/规则/审计关系按需查询" + : isYunyouLiboGraph + ? "快速查询已按荔波县酒店、美食、交通、景点、公交和空间关系配置" + : "支持自然语言和 Cypher"} · 单击看详情 · 双击展开关联 · 滚轮缩放 · 拖拽平移 {queryMeta?.cypher && ( diff --git a/admin-web/src/panels/plaza/ManualIngestPanel.tsx b/admin-web/src/panels/plaza/ManualIngestPanel.tsx index ce4f9f9..94a17aa 100644 --- a/admin-web/src/panels/plaza/ManualIngestPanel.tsx +++ b/admin-web/src/panels/plaza/ManualIngestPanel.tsx @@ -21,6 +21,7 @@ import { import { ArrowLeftOutlined, CheckCircleOutlined, + FileMarkdownOutlined, FileSearchOutlined, InboxOutlined, SearchOutlined, @@ -122,6 +123,7 @@ type ManualTaskRow = { status: string; owner: string; createdAt: string; + markdownDocument?: string; error?: string; }; @@ -144,6 +146,8 @@ type ManualIngestSession = { taskCreatedAt: string; taskRecord: ManualTaskRow | null; result: ExtractResult | null; + convertedMarkdownFile?: string; + convertedMarkdownPath?: string; updatedAt: number; }; @@ -481,6 +485,32 @@ function SourceHighlighter({ text, hit }: { text: string; hit: EvidenceHit | nul return
{text}
; } +function MarkdownPreview({ markdown }: { markdown: string }) { + if (!markdown.trim()) { + return ; + } + return ( +
+ {markdown.split(/\r?\n/).map((line, index) => { + const key = `${index}-${line.slice(0, 16)}`; + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4; + return {heading[2]}; + } + if (!line.trim()) return
; + if (/^\s*([-*+]|\d+[.)])\s+\S/.test(line)) { + return
{line.replace(/^\s*([-*+]|\d+[.)])\s+/, "")}
; + } + if (line.includes("|") && line.split("|").length >= 3) { + return
{line}
; + } + return

{line}

; + })} +
+ ); +} + function connectedPreviewGraph(graph?: { nodes: KgRow[]; edges: KgRow[] }) { const allNodes = graph?.nodes || []; const nodeIds = new Set(allNodes.map((n) => String(n.id))); @@ -713,6 +743,14 @@ export default function ManualIngestPanel() { const [taskRecord, setTaskRecord] = useState(null); const [hit, setHit] = useState(null); const [sessionHydrated, setSessionHydrated] = useState(false); + const [fileConverting, setFileConverting] = useState(false); + const [convertedMarkdownFile, setConvertedMarkdownFile] = useState(""); + const [convertedMarkdownPath, setConvertedMarkdownPath] = useState(""); + const [markdownPreviewOpen, setMarkdownPreviewOpen] = useState(false); + const [markdownPreviewLoading, setMarkdownPreviewLoading] = useState(false); + const [markdownPreviewText, setMarkdownPreviewText] = useState(""); + const [markdownPreviewPath, setMarkdownPreviewPath] = useState(""); + const [markdownPreviewTab, setMarkdownPreviewTab] = useState("preview"); const summary = result?.summary || {}; const hasResult = !!result; @@ -748,7 +786,10 @@ export default function ManualIngestPanel() { setTaskCreatedAt(cached.taskCreatedAt || ""); setTaskRecord(cached.taskRecord || null); setResult(cached.result || null); + setConvertedMarkdownFile(cached.convertedMarkdownFile || ""); + setConvertedMarkdownPath(cached.convertedMarkdownPath || ""); setLoading(false); + setFileConverting(false); setHit(null); setResultOpen(false); setSessionHydrated(true); @@ -815,6 +856,10 @@ export default function ManualIngestPanel() { }; const runExtract = async () => { + if (fileConverting) { + message.info("请等待文件转换完成"); + return; + } if (!text.trim()) { message.warning("请先上传或粘贴文档内容"); return; @@ -838,6 +883,7 @@ export default function ManualIngestPanel() { status: "执行中", owner: "111111", createdAt, + markdownDocument: convertedMarkdownFile, }; setTaskCreatedAt(createdAt); setTaskRecord(pendingTask); @@ -866,6 +912,8 @@ export default function ManualIngestPanel() { taskCreatedAt: createdAt, taskRecord: pendingTask, result: null, + convertedMarkdownFile, + convertedMarkdownPath, updatedAt: Date.now(), }, true); try { @@ -908,6 +956,8 @@ export default function ManualIngestPanel() { taskCreatedAt: createdAt, taskRecord: completedTask, result: data, + convertedMarkdownFile, + convertedMarkdownPath, updatedAt: Date.now(), }, true); setResultOpen(false); @@ -937,6 +987,8 @@ export default function ManualIngestPanel() { taskCreatedAt: createdAt, taskRecord: failedTask, result: null, + convertedMarkdownFile, + convertedMarkdownPath, updatedAt: Date.now(), }, true); message.error(errorDetail); @@ -998,6 +1050,7 @@ export default function ManualIngestPanel() { const taskRows = taskRecord ? [taskRecord] : []; const displayTaskName = taskRecord?.name || documentName; const displayUploadDocument = taskRecord?.document || uploadDocument; + const displayMarkdownDocument = taskRecord?.markdownDocument || convertedMarkdownFile || convertedMarkdownPath; const displayBusinessScene = taskRecord?.businessScene || selectedScene.label; const displaySceneRule = taskRecord?.sceneRule || selectedScene.schema; const displaySegmentStrategy = taskRecord?.segmentStrategy || STRATEGY_LABELS[extractStrategy]; @@ -1008,6 +1061,9 @@ export default function ManualIngestPanel() { const logLines = [ `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: create knowledge extraction task:${displayTaskName}`, `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: upload document:${displayUploadDocument}`, + displayMarkdownDocument + ? `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: markitdown markdown:${displayMarkdownDocument}` + : "", `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: business scene:${displayBusinessScene}`, `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: auto schema:${displaySceneRule}`, `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: knowledge llm:${displayExtractModel}`, @@ -1115,6 +1171,8 @@ export default function ManualIngestPanel() { taskCreatedAt, taskRecord, result, + convertedMarkdownFile, + convertedMarkdownPath, updatedAt: Date.now(), }); }, [ @@ -1136,6 +1194,8 @@ export default function ManualIngestPanel() { taskCreatedAt, taskRecord, result, + convertedMarkdownFile, + convertedMarkdownPath, ]); const fillSample = () => { @@ -1157,6 +1217,11 @@ export default function ManualIngestPanel() { setAllowSchemaExtra(false); setDirectImport(false); setText(""); + setConvertedMarkdownFile(""); + setConvertedMarkdownPath(""); + setMarkdownPreviewOpen(false); + setMarkdownPreviewText(""); + setMarkdownPreviewPath(""); setHit(null); setWizardStep(0); }; @@ -1190,8 +1255,61 @@ export default function ManualIngestPanel() { setBusinessScene(nextScene.scene); }; + const convertUploadToMarkdown = async (file: File) => { + setFileConverting(true); + setHit(null); + const formData = new FormData(); + formData.append("file", file); + try { + const { data } = await api.post("/manual-ingest/convert-file", formData); + const markdown = String(data?.markdown || ""); + if (!markdown.trim()) { + message.warning("文件已上传,但未转换出可抽取文本"); + return; + } + const filename = String(data?.filename || file.name); + const markdownPath = String(data?.markdown_path || ""); + setText(markdown); + setSourceUrl(filename); + setFormat("markdown"); + setConvertedMarkdownFile(markdownPath || `${filename}.markitdown.md`); + setConvertedMarkdownPath(markdownPath); + if (!sourceName.trim()) { + setSourceName(filename.replace(/\.[^.]+$/, "")); + } + message.success(`已转换为 MarkItDown Markdown,共 ${Number(data?.markdown_chars || markdown.length)} 字`); + } catch (err: any) { + message.error(err?.response?.data?.detail || err?.message || "文件转换失败"); + } finally { + setFileConverting(false); + } + }; + + const openMarkdownPreview = async () => { + const path = convertedMarkdownPath || convertedMarkdownFile; + setMarkdownPreviewPath(path); + setMarkdownPreviewText(text); + setMarkdownPreviewTab("preview"); + setMarkdownPreviewOpen(true); + if (!path) return; + setMarkdownPreviewLoading(true); + try { + const { data } = await api.get("/manual-ingest/markdown-preview", { params: { path } }); + setMarkdownPreviewText(String(data?.markdown || text)); + setMarkdownPreviewPath(String(data?.markdown_path || path)); + } catch (err: any) { + message.warning(err?.response?.data?.detail || "读取 Markdown 文件失败,先显示当前文档内容"); + } finally { + setMarkdownPreviewLoading(false); + } + }; + const nextStep = () => { if (wizardStep === 0) { + if (fileConverting) { + message.info("请等待文件转换完成"); + return; + } if (!sourceName.trim()) { message.warning("请输入知识任务名称"); return; @@ -1232,22 +1350,27 @@ export default function ManualIngestPanel() { { - const reader = new FileReader(); - reader.onload = () => { - setText(String(reader.result || "")); - setSourceUrl(file.name); - }; - reader.readAsText(file); - return false; + void convertUploadToMarkdown(file as File); + return Upload.LIST_IGNORE; }} >

-

点击或将文件拖拽到这里上传

-

支持拓展名:txt,csv,json,xml,log,md,pdf

+

{fileConverting ? "正在转换为 MarkItDown Markdown..." : "点击或将文件拖拽到这里上传"}

+

支持 PDF、Word、PPT、Excel、HTML、Markdown、CSV、JSON、XML、图片、音频和 ZIP

+ {convertedMarkdownFile && ( + + )}
@@ -1256,7 +1379,7 @@ export default function ManualIngestPanel() { value={text} onChange={(event) => setText(event.target.value)} rows={5} - placeholder="也可以直接粘贴文档内容" + placeholder="上传文件会自动转换为 MarkItDown Markdown;也可以直接粘贴文档内容" />
@@ -1286,6 +1409,7 @@ export default function ManualIngestPanel() { onChange={setFormat} options={[ { value: "auto", label: "自动识别格式" }, + { value: "markdown", label: "MarkItDown Markdown" }, { value: "text", label: "自由文本" }, { value: "csv", label: "CSV / 表格" }, { value: "kv", label: "键值对" }, @@ -1495,11 +1619,53 @@ export default function ManualIngestPanel() { + setMarkdownPreviewOpen(false)} + width={820} + > + + + , + }, + { + key: "source", + label: "源码", + children: ( +